Agents convert-elixir-scala

Bidirectional conversion between Elixir and Scala. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elixir↔Scala specific patterns. Use when migrating Elixir/Phoenix applications to Scala, translating BEAM OTP patterns to Akka actors, or refactoring dynamic concurrency to statically-typed JVM patterns. Extends meta-convert-dev with Elixir-to-Scala specific patterns.

install
source · Clone the upstream repo
git clone https://github.com/aRustyDev/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-elixir-scala" ~/.claude/skills/arustydev-agents-convert-elixir-scala && rm -rf "$T"
manifest: content/skills/convert-elixir-scala/SKILL.md
source content

Elixir ↔ Scala Conversion

Bidirectional conversion between Elixir and Scala. This skill extends

meta-convert-dev
with Elixir↔Scala specific type mappings, idiom translations, and tooling for translating between BEAM VM's dynamic actors and JVM's statically-typed Akka actors.

This Skill Extends

  • meta-convert-dev
    - Foundational conversion patterns (APTV workflow, testing strategies)

For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.

This Skill Adds

  • Type mappings: Elixir types (dynamic) → Scala types (static)
  • Idiom translations: Elixir patterns → idiomatic Scala with type safety
  • Runtime transition: BEAM VM (lightweight processes) → JVM (Akka actors/Futures)
  • Concurrency models: GenServer/Supervisor → Akka actors with typed protocols
  • OTP patterns: Supervision trees → Akka supervision hierarchies
  • Data structures: Elixir maps/structs → Scala case classes
  • Error handling: {:ok/:error} tuples → Either/Try monads
  • Build tools: Mix → sbt/Mill

This Skill Does NOT Cover

  • General conversion methodology - see
    meta-convert-dev
  • Elixir language fundamentals - see
    lang-elixir-dev
  • Scala language fundamentals - see
    lang-scala-dev
  • Phoenix framework specifics - see
    lang-scala-play-dev
    for web frameworks

Quick Reference

ElixirScalaNotes
:atom
Symbol / sealed traitAtoms → sealed traits for exhaustiveness
{:ok, value}
Right(value)
Result tuples → Either/Try
{:error, reason}
Left(reason)
Error tuples → Either
%{key: value}
Map("key" -> value)
Maps require type parameters
[1, 2, 3]
List(1, 2, 3)
Lists immutable by default
defmodule Module
object Module
Module → singleton object
def func(x)
def func(x: Type): ReturnType
Functions require type signatures
fn x -> x end
(x: Type) => x
Anonymous functions
|>
(pipe)
.map().flatMap()
Pipe → method chaining
GenServer
Actor[Protocol]
Typed actors in Akka
Supervisor
ActorSystem
/
Behaviors.supervise
Supervision strategies
Task.async
Future { }
Async execution
Pattern matching
match/case
Similar but with static types

8 Pillars Validation

Elixir Coverage

PillarCoverageStatus
Module Systemdefmodule, alias, import, use
Error Handling{:ok/:error} tuples, pattern matching, try/rescue
Concurrency ModelProcesses, GenServer, Supervisor, Task
MetaprogrammingMacros, use directive, compile-time generation
Zero/Default Valuesnil, pattern matching with defaults
SerializationPhoenix params, Jason/Poison, Ecto
Build/DepsMix, hex.pm, deps management
Testing IdiomsExUnit, doctests, Mox for mocking

Score: 8/8 (Green)

Scala Coverage

PillarCoverageStatus
Module SystemPackages, objects, imports, traits
Error HandlingEither, Try, Option, exceptions
Concurrency ModelFutures, Akka actors, Cats Effect IO
MetaprogrammingMacros (Scala 2), inline/metaprogramming (Scala 3)
Zero/Default ValuesOption, default parameters, null (avoid)
SerializationPlay JSON, Circe, uPickle, case classes
Build/Depssbt, Mill, Maven Central
Testing IdiomsScalaTest, ScalaCheck, MUnit, specs2

Score: 8/8 (Green)

Validation Status: ✅ Both languages have comprehensive coverage. Conversion can proceed with confidence.

Key Challenge: Elixir's dynamic typing to Scala's static typing requires careful type inference and explicit type annotations.


Type System Mapping

Primitive Types

ElixirScalaNotes
:atom
Symbol
/ sealed trait
Use sealed traits for ADTs
integer
Int
/
Long
/
BigInt
Choose based on range needs
float
Double
64-bit floating point
true
/
false
Boolean
Direct mapping
nil
None
/
null
(avoid)
Prefer Option[T]
"string"
String
Immutable strings
'charlist'
List[Char]
Less common in Scala

Collection Types

ElixirScalaNotes
[1, 2, 3]
List(1, 2, 3)
Immutable linked lists
{:ok, value}
Right(value): Either[Error, Value]
Tagged tuples → Either
{a, b, c}
(a, b, c): (A, B, C)
Tuples map directly
%{a: 1, b: 2}
Map("a" -> 1, "b" -> 2)
Immutable maps
MapSet.new([1, 2])
Set(1, 2)
Immutable sets
Keyword list
List[(Symbol, A)]
Less common in Scala
Range
1..10
Range(1, 11)
Inclusive → exclusive end

Composite Types

ElixirScalaNotes
defstruct [:name, :age]
case class User(name: String, age: Int)
Structs → case classes
%User{name: "Alice"}
User("Alice", 30)
Constructor syntax
@type user :: %{name: String.t()}
type User = Map[String, Any]
Type aliases
@spec func(integer) :: String.t()
def func(x: Int): String
Function signatures required
ProtocolTraitPolymorphism via traits

Idiom Translation

Pattern: Module Definition

Elixir:

defmodule MyApp.Users do
  @moduledoc """
  Handles user-related operations.
  """

  alias MyApp.Repo
  import Ecto.Query

  @type user :: %{id: integer, name: String.t()}

  @spec get_user(integer) :: {:ok, user} | {:error, :not_found}
  def get_user(id) do
    case Repo.get(User, id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end

  defp build_query(filters) do
    from u in User, where: ^filters
  end
end

Scala:

package myapp

/**
 * Handles user-related operations.
 */
object Users {
  import myapp.repo.Repo
  import myapp.models.User

  type UserId = Int
  type UserResult = Either[UserError, User]

  sealed trait UserError
  case object NotFound extends UserError

  def getUser(id: UserId): UserResult = {
    Repo.get[User](id) match {
      case Some(user) => Right(user)
      case None => Left(NotFound)
    }
  }

  private def buildQuery(filters: Map[String, Any]): Query = {
    Query.from[User].where(filters)
  }
}

Why this translation:

  • defmodule
    object
    (singleton)
  • @moduledoc
    → Scaladoc
    /** */
  • @type
    type
    alias with explicit types
  • @spec
    → method signature with types
  • {:ok/:error}
    tuples →
    Either[Error, Value]
  • Pattern matching on nil →
    Option.match
  • CamelCase modules → package + object structure

Pattern: Pattern Matching to Case Classes

Elixir:

def process_response({:ok, %{"data" => data, "meta" => meta}}) do
  {:success, data, meta}
end

def process_response({:error, reason}) do
  {:failure, reason}
end

def handle_user(%User{name: name, age: age}) when age >= 18 do
  "Adult: #{name}"
end

Scala:

sealed trait Response
case class Success(data: String, meta: Map[String, Any]) extends Response
case class Error(reason: String) extends Response

def processResponse(response: Response): Result = response match {
  case Success(data, meta) =>
    SuccessResult(data, meta)
  case Error(reason) =>
    FailureResult(reason)
}

case class User(name: String, age: Int)

def handleUser(user: User): Option[String] = user match {
  case User(name, age) if age >= 18 =>
    Some(s"Adult: $name")
  case _ =>
    None
}

Why this translation:

  • Multiple function clauses → sealed trait with case classes
  • Guards → pattern guards with
    if
    in match
  • Struct pattern matching → case class pattern matching
  • Tag tuples → sealed trait hierarchy for type safety
  • Exhaustiveness checking enforced by compiler

Pattern: Pipelines to Method Chaining

Elixir:

def process_data(input) do
  input
  |> String.trim()
  |> String.downcase()
  |> String.split(",")
  |> Enum.map(&String.trim/1)
  |> Enum.reject(&(&1 == ""))
  |> Enum.join(";")
end

Scala:

def processData(input: String): String = {
  input
    .trim
    .toLowerCase
    .split(",")
    .map(_.trim)
    .filterNot(_.isEmpty)
    .mkString(";")
}

// Or with explicit types for clarity
def processData(input: String): String = {
  val trimmed: String = input.trim
  val lowercased: String = trimmed.toLowerCase
  val parts: Array[String] = lowercased.split(",")
  val cleaned: Array[String] = parts.map(_.trim).filterNot(_.isEmpty)
  cleaned.mkString(";")
}

Why this translation:

  • |>
    → method chaining (
    .
    )
  • Enum functions → collection methods
  • Capture operator
    &func/arity
    → anonymous functions
    _
  • Enum.reject
    filterNot
  • Enum.join
    mkString

Pattern: GenServer to Akka Typed Actor

Elixir:

defmodule UserCache do
  use GenServer

  # Client API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def get(key) do
    GenServer.call(__MODULE__, {:get, key})
  end

  def put(key, value) do
    GenServer.cast(__MODULE__, {:put, key, value})
  end

  # Server Callbacks

  @impl true
  def init(_opts) do
    {:ok, %{}}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end
end

Scala (Akka Typed):

import akka.actor.typed._
import akka.actor.typed.scaladsl.Behaviors

object UserCache {
  // Protocol
  sealed trait Command
  final case class Get(key: String, replyTo: ActorRef[Option[String]])
    extends Command
  final case class Put(key: String, value: String)
    extends Command

  // Behavior
  def apply(): Behavior[Command] = {
    behavior(Map.empty[String, String])
  }

  private def behavior(cache: Map[String, String]): Behavior[Command] = {
    Behaviors.receive { (context, message) =>
      message match {
        case Get(key, replyTo) =>
          replyTo ! cache.get(key)
          Behaviors.same

        case Put(key, value) =>
          behavior(cache + (key -> value))
      }
    }
  }
}

// Usage
val system: ActorSystem[UserCache.Command] =
  ActorSystem(UserCache(), "user-cache-system")

implicit val timeout: Timeout = 3.seconds
val futureResult: Future[Option[String]] =
  system.ask(ref => UserCache.Get("key", ref))

Why this translation:

  • GenServer
    → Akka Typed
    Actor[Protocol]
  • Message tuples → sealed trait protocol
  • handle_call
    → synchronous message handling with
    ask
    pattern
  • handle_cast
    → asynchronous message handling
  • State recursion → immutable state updates
  • Type safety enforced at compile time

Pattern: Supervisor to Akka Supervision

Elixir:

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {UserCache, []},
      {MyWorker, name: MyWorker, restart: :transient},
      {Registry, keys: :unique, name: MyApp.Registry}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Scala (Akka Typed):

import akka.actor.typed._
import akka.actor.typed.scaladsl._

object MyApp {
  sealed trait RootCommand

  def apply(): Behavior[RootCommand] = {
    Behaviors.setup { context =>
      // Spawn supervised children
      val userCache = context.spawn(
        UserCache(),
        "user-cache"
      )

      val worker = context.spawn(
        Behaviors.supervise(MyWorker())
          .onFailure[Exception](SupervisorStrategy.restart),
        "my-worker"
      )

      // Root behavior
      Behaviors.receiveMessage { message =>
        Behaviors.same
      }
    }
  }
}

// Starting the system
val system = ActorSystem(MyApp(), "my-app-system")

Why this translation:

  • Supervision tree →
    Behaviors.setup
    with spawned children
  • :one_for_one
    → individual supervision per actor
  • :transient
    restart →
    SupervisorStrategy.restart.withLimit
  • Named processes → named actors via
    spawn
  • Declarative children list → imperative spawn calls

Error Handling

Elixir Error Model → Scala Error Model

Elixir uses tagged tuples:

def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}

# Usage
case divide(10, 2) do
  {:ok, result} -> "Result: #{result}"
  {:error, reason} -> "Error: #{reason}"
end

# Or with pattern matching
{:ok, result} = divide(10, 2)

Scala uses Either/Try monads:

sealed trait MathError
case object DivisionByZero extends MathError

def divide(a: Double, b: Double): Either[MathError, Double] = {
  if (b != 0) Right(a / b)
  else Left(DivisionByZero)
}

// Usage with pattern matching
divide(10, 2) match {
  case Right(result) => s"Result: $result"
  case Left(error) => s"Error: $error"
}

// Or with for-comprehension
val result = for {
  x <- divide(10, 2)
  y <- divide(x, 5)
} yield y

// Or with Try for exceptions
import scala.util.{Try, Success, Failure}

def divideWithTry(a: Double, b: Double): Try[Double] = {
  Try(a / b)
}

divideWithTry(10, 0) match {
  case Success(value) => s"Result: $value"
  case Failure(exception) => s"Error: ${exception.getMessage}"
}

Translation strategy:

  1. {:ok, value}
    Right(value): Either[Error, Value]
  2. {:error, reason}
    Left(reason): Either[Error, Value]
  3. Define error ADT with sealed trait
  4. Use for-comprehensions for chaining operations
  5. Consider
    Try
    for exception-based code

Concurrency Patterns

Elixir Async → Scala Async

Elixir Task-based async:

def fetch_user_data(user_id) do
  task1 = Task.async(fn -> fetch_profile(user_id) end)
  task2 = Task.async(fn -> fetch_orders(user_id) end)
  task3 = Task.async(fn -> fetch_preferences(user_id) end)

  profile = Task.await(task1)
  orders = Task.await(task2)
  preferences = Task.await(task3)

  %{profile: profile, orders: orders, preferences: preferences}
end

Scala Future-based async:

import scala.concurrent.{Future, Await}
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global

def fetchUserData(userId: Int): Future[UserData] = {
  val profileF = Future { fetchProfile(userId) }
  val ordersF = Future { fetchOrders(userId) }
  val preferencesF = Future { fetchPreferences(userId) }

  for {
    profile <- profileF
    orders <- ordersF
    preferences <- preferencesF
  } yield UserData(profile, orders, preferences)
}

// Or with Applicative for parallel execution
import cats.implicits._

def fetchUserDataParallel(userId: Int): Future[UserData] = {
  (
    Future { fetchProfile(userId) },
    Future { fetchOrders(userId) },
    Future { fetchPreferences(userId) }
  ).mapN(UserData.apply)
}

Why this translation:

  • Task.async
    Future { }
  • Task.await
    Await.result
    (avoid) or for-comprehension
  • Parallel execution → spawn all Futures immediately
  • Sequential composition → for-comprehension
  • Use Cats for applicative parallel composition

Process Message Passing → Actor Tell/Ask

Elixir:

defmodule Counter do
  def start do
    spawn(fn -> loop(0) end)
  end

  defp loop(count) do
    receive do
      {:increment, caller} ->
        send(caller, {:ok, count + 1})
        loop(count + 1)

      {:get, caller} ->
        send(caller, {:ok, count})
        loop(count)

      :stop ->
        :ok
    end
  end
end

# Usage
counter = Counter.start()
send(counter, {:increment, self()})
receive do
  {:ok, new_value} -> IO.puts("New value: #{new_value}")
end

Scala (Akka Typed):

object Counter {
  sealed trait Command
  case class Increment(replyTo: ActorRef[Response]) extends Command
  case class Get(replyTo: ActorRef[Response]) extends Command
  case object Stop extends Command

  sealed trait Response
  case class Value(count: Int) extends Response

  def apply(initialCount: Int): Behavior[Command] = {
    counter(initialCount)
  }

  private def counter(count: Int): Behavior[Command] = {
    Behaviors.receive { (context, message) =>
      message match {
        case Increment(replyTo) =>
          replyTo ! Value(count + 1)
          counter(count + 1)

        case Get(replyTo) =>
          replyTo ! Value(count)
          Behaviors.same

        case Stop =>
          Behaviors.stopped
      }
    }
  }
}

// Usage
val system = ActorSystem(Counter(0), "counter-system")
implicit val timeout: Timeout = 3.seconds

val futureCount: Future[Counter.Value] =
  system.ask(ref => Counter.Get(ref))

Why this translation:

  • spawn
    ActorSystem
    /
    context.spawn
  • send
    → actor
    !
    (tell) operator
  • receive
    Behaviors.receive
    pattern matching
  • Message tuples → sealed trait protocol
  • Tail recursion → return new behavior

Common Pitfalls

1. Dynamic Typing → Static Typing

Problem: Elixir allows dynamic typing; Scala requires explicit types.

# Elixir - dynamic
def process(data) do
  case data do
    x when is_integer(x) -> x * 2
    x when is_binary(x) -> String.length(x)
    x when is_list(x) -> length(x)
  end
end

Solution: Use sealed trait ADT for type safety.

sealed trait Data
case class IntData(value: Int) extends Data
case class StringData(value: String) extends Data
case class ListData[A](values: List[A]) extends Data

def process(data: Data): Int = data match {
  case IntData(x) => x * 2
  case StringData(x) => x.length
  case ListData(xs) => xs.length
}

2. Pattern Match Exhaustiveness

Problem: Elixir warns on non-exhaustive matches; Scala errors.

# Elixir - warning only
def handle({:ok, value}), do: value
# Missing {:error, _} case

Solution: Always handle all cases or use sealed traits.

sealed trait Result[+A]
case class Success[A](value: A) extends Result[A]
case class Failure(error: String) extends Result[Nothing]

def handle[A](result: Result[A]): A = result match {
  case Success(value) => value
  case Failure(error) => throw new Exception(error)
  // Compiler enforces exhaustiveness
}

3. Nil Safety

Problem: Elixir has

nil
; Scala should use
Option
.

# Elixir
user = Repo.get(User, id)  # Can be nil
name = user.name           # Runtime error if nil

Solution: Use Option type.

val user: Option[User] = Repo.get[User](id)
val name: Option[String] = user.map(_.name)

// Or with for-comprehension
val name = for {
  u <- user
} yield u.name

4. Immutability by Convention → Enforced

Problem: Elixir immutability is by convention; Scala enforces it.

# Elixir - creates new map
map = %{a: 1}
new_map = Map.put(map, :b, 2)

Solution: Use

val
and immutable collections.

val map = Map("a" -> 1)
val newMap = map + ("b" -> 2)  // Creates new map

// Avoid var and mutable collections
var mutableMap = scala.collection.mutable.Map("a" -> 1)  // Avoid

5. Process Supervision → Actor Supervision

Problem: Different restart semantics and error handling.

Elixir:

# Let it crash - supervisor restarts
def handle_call(:dangerous, _from, state) do
  result = do_dangerous_operation()  # Might crash
  {:reply, result, state}
end

Scala:

// Must handle errors explicitly or define supervision strategy
Behaviors.supervise {
  Behaviors.receiveMessage {
    case Dangerous =>
      try {
        val result = doDangerousOperation()
        Behaviors.same
      } catch {
        case e: Exception =>
          throw e  // Supervisor handles via strategy
      }
  }
}.onFailure[Exception](SupervisorStrategy.restart)

Tooling

CategoryElixirScalaNotes
Build ToolMixsbt, MillMill is more modern
Package ManagerHexMaven Centralsbt resolves dependencies
REPLiexscala / ammoniteAmmonite more feature-rich
Formattermix formatscalafmtBoth auto-format
LinterCredoscalafix, WartremoverScala has multiple
TestingExUnitScalaTest, MUnitMultiple test frameworks
Property TestingStreamDataScalaCheckSimilar concepts
Actor SystemOTPAkkaAkka Typed recommended
Web FrameworkPhoenixPlay, Http4s, Akka HTTPPlay most similar
DBEctoSlick, Doobie, QuillDifferent approaches
JSONJason, PoisonCirce, Play JSON, uPickleMacro-based derivation
HTTP ClientHTTPoison, Teslasttp, http4s-clientFunctional HTTP clients

Examples

Example 1: Simple - Data Transformation

Before (Elixir):

defmodule DataProcessor do
  def transform_user(%{name: name, age: age}) do
    %{
      display_name: String.upcase(name),
      is_adult: age >= 18,
      category: categorize_age(age)
    }
  end

  defp categorize_age(age) when age < 13, do: :child
  defp categorize_age(age) when age < 20, do: :teenager
  defp categorize_age(_), do: :adult
end

After (Scala):

object DataProcessor {
  case class User(name: String, age: Int)

  sealed trait AgeCategory
  case object Child extends AgeCategory
  case object Teenager extends AgeCategory
  case object Adult extends AgeCategory

  case class TransformedUser(
    displayName: String,
    isAdult: Boolean,
    category: AgeCategory
  )

  def transformUser(user: User): TransformedUser = {
    TransformedUser(
      displayName = user.name.toUpperCase,
      isAdult = user.age >= 18,
      category = categorizeAge(user.age)
    )
  }

  private def categorizeAge(age: Int): AgeCategory = age match {
    case a if a < 13 => Child
    case a if a < 20 => Teenager
    case _ => Adult
  }
}

Example 2: Medium - Error Handling Pipeline

Before (Elixir):

defmodule UserRegistration do
  def register(params) do
    with {:ok, validated} <- validate_params(params),
         {:ok, user} <- create_user(validated),
         {:ok, _email} <- send_welcome_email(user) do
      {:ok, user}
    else
      {:error, :invalid_email} -> {:error, "Email is invalid"}
      {:error, :user_exists} -> {:error, "User already exists"}
      {:error, reason} -> {:error, "Registration failed: #{reason}"}
    end
  end

  defp validate_params(%{email: email, password: password})
    when byte_size(password) >= 8 do
    if valid_email?(email) do
      {:ok, %{email: email, password: password}}
    else
      {:error, :invalid_email}
    end
  end
  defp validate_params(_), do: {:error, :invalid_params}
end

After (Scala):

object UserRegistration {
  case class UserParams(email: String, password: String)
  case class User(id: Int, email: String)

  sealed trait RegistrationError
  case object InvalidEmail extends RegistrationError
  case object UserExists extends RegistrationError
  case class GeneralError(message: String) extends RegistrationError

  type RegistrationResult = Either[RegistrationError, User]

  def register(params: UserParams): RegistrationResult = {
    for {
      validated <- validateParams(params)
      user <- createUser(validated)
      _ <- sendWelcomeEmail(user)
    } yield user
  }

  private def validateParams(
    params: UserParams
  ): Either[RegistrationError, UserParams] = {
    if (params.password.length < 8) {
      Left(GeneralError("Password too short"))
    } else if (!validEmail(params.email)) {
      Left(InvalidEmail)
    } else {
      Right(params)
    }
  }

  private def createUser(
    params: UserParams
  ): Either[RegistrationError, User] = {
    // Database logic
    Right(User(1, params.email))
  }

  private def sendWelcomeEmail(
    user: User
  ): Either[RegistrationError, Unit] = {
    // Email logic
    Right(())
  }

  private def validEmail(email: String): Boolean = {
    email.contains("@")
  }
}

Example 3: Complex - GenServer to Akka Actor with State

Before (Elixir):

defmodule RateLimiter do
  use GenServer
  require Logger

  defstruct requests: %{}, window_ms: 60_000, max_requests: 100

  # Client API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def check_rate(user_id) do
    GenServer.call(__MODULE__, {:check_rate, user_id})
  end

  def reset(user_id) do
    GenServer.cast(__MODULE__, {:reset, user_id})
  end

  # Server Callbacks

  @impl true
  def init(opts) do
    state = %__MODULE__{
      window_ms: Keyword.get(opts, :window_ms, 60_000),
      max_requests: Keyword.get(opts, :max_requests, 100)
    }
    {:ok, state}
  end

  @impl true
  def handle_call({:check_rate, user_id}, _from, state) do
    now = System.system_time(:millisecond)
    requests = Map.get(state.requests, user_id, [])

    # Filter out expired requests
    valid_requests =
      Enum.filter(requests, fn ts ->
        now - ts < state.window_ms
      end)

    count = length(valid_requests)

    if count < state.max_requests do
      new_requests = [now | valid_requests]
      new_state = put_in(state.requests[user_id], new_requests)
      {:reply, {:ok, state.max_requests - count - 1}, new_state}
    else
      {:reply, {:error, :rate_limit_exceeded}, state}
    end
  end

  @impl true
  def handle_cast({:reset, user_id}, state) do
    new_state = put_in(state.requests[user_id], [])
    {:noreply, new_state}
  end

  @impl true
  def handle_info(:cleanup, state) do
    now = System.system_time(:millisecond)

    new_requests =
      state.requests
      |> Enum.map(fn {user_id, requests} ->
        valid = Enum.filter(requests, fn ts ->
          now - ts < state.window_ms
        end)
        {user_id, valid}
      end)
      |> Enum.into(%{})

    Process.send_after(self(), :cleanup, 60_000)
    {:noreply, %{state | requests: new_requests}}
  end
end

After (Scala with Akka Typed):

import akka.actor.typed._
import akka.actor.typed.scaladsl._
import scala.concurrent.duration._

object RateLimiter {
  // Protocol
  sealed trait Command
  final case class CheckRate(
    userId: String,
    replyTo: ActorRef[RateResponse]
  ) extends Command
  final case class Reset(userId: String) extends Command
  private case object Cleanup extends Command

  // Responses
  sealed trait RateResponse
  final case class Allowed(remaining: Int) extends RateResponse
  case object RateLimitExceeded extends RateResponse

  // Configuration
  case class Config(
    windowMs: Long = 60000,
    maxRequests: Int = 100
  )

  // State
  private case class State(
    requests: Map[String, List[Long]],
    config: Config
  )

  def apply(config: Config = Config()): Behavior[Command] = {
    Behaviors.setup { context =>
      Behaviors.withTimers { timers =>
        timers.startTimerWithFixedDelay(Cleanup, 60.seconds)
        active(State(Map.empty, config), context)
      }
    }
  }

  private def active(
    state: State,
    context: ActorContext[Command]
  ): Behavior[Command] = {
    Behaviors.receiveMessage {
      case CheckRate(userId, replyTo) =>
        val now = System.currentTimeMillis()
        val requests = state.requests.getOrElse(userId, List.empty)

        // Filter expired requests
        val validRequests = requests.filter { ts =>
          now - ts < state.config.windowMs
        }

        val count = validRequests.length

        if (count < state.config.maxRequests) {
          val newRequests = now :: validRequests
          val newState = state.copy(
            requests = state.requests + (userId -> newRequests)
          )
          replyTo ! Allowed(state.config.maxRequests - count - 1)
          active(newState, context)
        } else {
          replyTo ! RateLimitExceeded
          Behaviors.same
        }

      case Reset(userId) =>
        val newState = state.copy(
          requests = state.requests - userId
        )
        active(newState, context)

      case Cleanup =>
        val now = System.currentTimeMillis()
        val cleaned = state.requests.map { case (userId, requests) =>
          val valid = requests.filter { ts =>
            now - ts < state.config.windowMs
          }
          userId -> valid
        }.filter(_._2.nonEmpty)

        active(state.copy(requests = cleaned), context)
    }
  }
}

// Usage
val system: ActorSystem[RateLimiter.Command] =
  ActorSystem(RateLimiter(RateLimiter.Config()), "rate-limiter")

implicit val timeout: Timeout = 3.seconds
val futureResponse: Future[RateLimiter.RateResponse] =
  system.ask(ref => RateLimiter.CheckRate("user123", ref))

See Also

For more examples and patterns, see:

  • meta-convert-dev
    - Foundational patterns with cross-language examples
  • lang-elixir-dev
    - Elixir development patterns
  • lang-scala-dev
    - Scala development patterns
  • lang-scala-akka-dev
    - Advanced Akka patterns

Cross-cutting pattern skills:

  • patterns-concurrency-dev
    - Async, actors, processes across languages
  • patterns-serialization-dev
    - JSON, validation across languages
  • patterns-metaprogramming-dev
    - Macros, code generation across languages