Building a Parking Lot System with Elixir OTP: A Journey from Traditional to Domain-Driven Design

Revisiting “Designing Elixir Systems with OTP” by Bruce Tate and applying its principles to build a robust parking lot management system


What You’ll Learn

  • How to structure Elixir applications using domain-driven design principles
  • Implementing clean architecture with OTP processes and proper supervision
  • Building fault-tolerant systems with process isolation
  • Practical patterns from “Designing Elixir Systems with OTP”
  • When to choose this architecture over simpler approaches

The Backstory

A few years ago, I read Bruce Tate’s excellent book “Designing Elixir Systems with OTP” and was fascinated by the clean architecture patterns it presented. Coming from a traditional web development background where I’d typically reach for Ecto schemas, write models, add validation, and manage state with basic GenServers without proper architectural boundaries, I wanted to revisit these concepts with a fresh perspective.

What better way to practice than building a classic “parking lot” system? This time, I decided to focus purely on the domain logic and OTP patterns, leaving authentication concerns for later (though adding Plug-based auth would be straightforward).

The Challenge: Parking Lot Management

The parking lot system needed to handle:

  • Creating parking lots with configurable slot counts
  • Parking vehicles (registration number + color)
  • Releasing vehicles from slots
  • Querying by color, registration number, and slot status
  • Maintaining data consistency in a concurrent environment

Architecture Overview

The system follows a clean, layered architecture inspired by Domain-Driven Design principles:

Core Domain: The Heart of the System

1. ParkingLot — The Aggregate Root
defmodule ParkingLot.Core.ParkingLot do
  defstruct id: nil, slots: [], used_slots: []
  
  def new(slot_count) do
    with {:ok, validated_count} <- Validators.validate_slot_count(slot_count) do
      %__MODULE__{}
      |> add_id()
      |> add_slots(validated_count)
    end
  endCode language: Elixir (elixir)
defp add_id(parking_lot) do
    %{parking_lot | id: AtomicCounter.next()}
  end  defp add_slots(parking_lot, count) do
    slots = Enum.map(1..count, fn _ -> 
      %Slot{id: AtomicCounter.next(), type: :normal}
    end)
    %{parking_lot | slots: slots}
  end
endCode language: Elixir (elixir)

The ParkingLot struct represents our main aggregate, managing the relationship between available and occupied slots. It encapsulates all the business rules around parking operations.

2. Value Objects: Slot, Vehicle, Ticket
defmodule ParkingLot.Core.Slot do
  @enforce_keys ~w(id)a
  defstruct ~w(id ticket_id type)a
end

defmodule ParkingLot.Core.Vehicle do
  @enforce_keys ~w(color registration_no)a
  defstruct ~w(color registration_no)a
end
defmodule ParkingLot.Core.Ticket do
  @enforce_keys ~w(id slot_id vehicle)a
  defstruct ~w(id slot_id vehicle timestamp)a
  
  def new(slot_id, vehicle) do
    %__MODULE__{
      id: AtomicCounter.next(),
      slot_id: slot_id,
      vehicle: vehicle,
      timestamp: DateTime.utc_now()
    }
  end
endCode language: Elixir (elixir)

These are simple, immutable data structures that represent domain concepts with minimal behavior.

3. Validation Layer
defmodule ParkingLot.Core.Validators do
  @min_slot_count 1
  @max_slot_count 1000

def validate_slot_count(count) when is_integer(count) do
    cond do
      count < @min_slot_count -> {:error, "Slot count must be at least #{@min_slot_count}"}
      count > @max_slot_count -> {:error, "Slot count cannot exceed #{@max_slot_count}"}
      true -> {:ok, count}
    end
  end
  def validate_registration_number(registration_no) when is_binary(registration_no) do
    trimmed = String.trim(registration_no)
    
    cond do
      String.length(trimmed) == 0 -> {:error, "Registration number cannot be empty"}
      String.length(trimmed) > 20 -> {:error, "Registration number cannot exceed 20 characters"}
      true -> {:ok, trimmed}
    end
  end
  def validate_color(color) when is_binary(color) do
    trimmed = String.trim(color) |> String.upcase()
    
    if String.length(trimmed) > 0 do
      {:ok, trimmed}
    else
      {:error, "Color cannot be empty"}
    end
  end
endCode language: Elixir (elixir)

Centralized validation logic ensures data integrity at the domain level.

Boundary Layer: Process Management with Supervision

Application Supervision Tree
defmodule ParkingLot.Application do
  use Application

def start(_type, _args) do
    slot_count = Application.get_env(:parking_lot, :default_slot_count, 6)
    
    children = [
      {ParkingLot.Core.AtomicCounter, []},
      {ParkingLot.Boundary.TicketManager, []},
      {ParkingLot.Boundary.ParkingManager, [slot_count: slot_count]}
    ]
    opts = [strategy: :one_for_one, name: ParkingLot.Supervisor]
    Supervisor.start_link(children, opts)
  end
endCode language: Elixir (elixir)
ParkingManager GenServer
defmodule ParkingLot.Boundary.ParkingManager do
  use GenServer
  alias ParkingLot.Core.ParkingLot, as: CoreParkingLot
  
  def start_link(opts) do
    slot_count = Keyword.get(opts, :slot_count, 6)
    GenServer.start_link(__MODULE__, slot_count, name: __MODULE__)
  end

def init(slot_count) do
    case CoreParkingLot.new(slot_count) do
      {:ok, parking_lot} -> {:ok, parking_lot}
      {:error, reason} -> {:stop, reason}
    end
  end
  def park(manager \\ __MODULE__, registration_no, color) do
    GenServer.call(manager, {:park, registration_no, color})
  end
  def leave(manager \\ __MODULE__, slot_id) do
    GenServer.call(manager, {:leave, slot_id})
  end
  def status(manager \\ __MODULE__) do
    GenServer.call(manager, :status)
  end
  
  def handle_call({:park, registration_no, color}, _from, state) do
    case CoreParkingLot.park(state, registration_no, color) do
      {:ok, ticket, new_state} -> 
        {:reply, {:ok, ticket.slot_id}, new_state}
      {:error, msg, state} -> 
        {:reply, {:error, msg}, state}
    end
  end
  def handle_call({:leave, slot_id}, _from, state) do
    case CoreParkingLot.leave(state, slot_id) do
      {:ok, new_state} -> 
        {:reply, :ok, new_state}
      {:error, msg, state} -> 
        {:reply, {:error, msg}, state}
    end
  end
  def handle_call(:status, _from, state) do
    {:reply, CoreParkingLot.status(state), state}
  end
endCode language: Elixir (elixir)
TicketManager GenServer
defmodule ParkingLot.Boundary.TicketManager do
  use GenServer

def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  def init(state), do: {:ok, state}
  def store_ticket(ticket_id, ticket_data) do
    GenServer.call(__MODULE__, {:store, ticket_id, ticket_data})
  end
  def get_ticket(ticket_id) do
    GenServer.call(__MODULE__, {:get, ticket_id})
  end
  def handle_call({:store, ticket_id, ticket_data}, _from, state) do
    new_state = Map.put(state, ticket_id, ticket_data)
    {:reply, :ok, new_state}
  end
  def handle_call({:get, ticket_id}, _from, state) do
    ticket = Map.get(state, ticket_id)
    {:reply, ticket, state}
  end
endCode language: Elixir (elixir)

A separate process manages ticket lifecycle, demonstrating the single responsibility principle.

Data Flow: How It All Works Together

1. Parking a Vehicle
User Request → Public API → ParkingManager → Core Domain → State Validation → ResponseCode language: Markdown (markdown)
  1. User calls ParkingLot.park("ABC-123", "RED")
  2. Public API validates inputs and delegates to ParkingManager
  3. ParkingManager calls CoreParkingLot.park/3
  4. Domain logic validates, creates ticket, updates state
  5. StateValidator ensures consistency
  6. Response returned to user
2. Core Domain Logic
defmodule ParkingLot.Core.ParkingLot do
  def park(parking_lot, registration_no, color) do
    with {:ok, validated_reg_no} <- Validators.validate_registration_number(registration_no),
         {:ok, validated_color} <- Validators.validate_color(color),
         {:ok, slot} <- get_available_slot(parking_lot),
         :ok <- StateValidator.validate_slot_operation(parking_lot, :park, slot),
         {:ok, vehicle} <- create_vehicle(validated_reg_no, validated_color),
         {:ok, ticket} <- create_ticket(slot, vehicle),
         new_slot <- add_ticket_to_slot(slot, ticket),
         parking_lot <- move_slot_to_used(new_slot, parking_lot),
         parking_lot <- remove_slot_from_available(slot, parking_lot),
         :ok <- StateValidator.validate_state(parking_lot) do
      {:ok, ticket, parking_lot}
    else
      {:error, msg} -> {:error, msg, parking_lot}
    end
  end

  defp get_available_slot(%{slots: []}), do: {:error, "Sorry, parking lot is full"}
  defp get_available_slot(%{slots: [slot | _]}), do: {:ok, slot}
  defp create_vehicle(registration_no, color) do
    {:ok, %Vehicle{registration_no: registration_no, color: color}}
  end
  defp create_ticket(slot, vehicle) do
    {:ok, Ticket.new(slot.id, vehicle)}
  end
  defp add_ticket_to_slot(slot, ticket) do
    %{slot | ticket_id: ticket.id}
  end
  defp move_slot_to_used(slot, parking_lot) do
    %{parking_lot | used_slots: [slot | parking_lot.used_slots]}
  end
  defp remove_slot_from_available(slot, parking_lot) do
    slots = List.delete(parking_lot.slots, slot)
    %{parking_lot | slots: slots}
  end
endCode language: Elixir (elixir)

The with statement ensures each step succeeds before proceeding, providing clear error handling.

Key Design Decisions

1. Process Isolation with Supervision

Each major concern runs in its own supervised process:

  • ParkingManager – handles parking operations
  • TicketManager – manages ticket lifecycle
  • AtomicCounter – provides thread-safe ID generation

If any process crashes, the supervisor restarts it without affecting other processes.

2. Immutable State

All state changes return new structs rather than mutating existing ones, following functional programming principles.

3. Comprehensive Validation

Input validation, business rule validation, and state validation each serve different purposes and are handled at appropriate layers.

4. Atomic Operations
defmodule ParkingLot.Core.AtomicCounter do
  use GenServer

def start_link(_opts) do
    GenServer.start_link(__MODULE__, 0, name: __MODULE__)
  end
  def next(server \\ __MODULE__) do
    GenServer.call(server, :next)
  end
  
  def handle_call(:next, _from, current_value) do
    new_value = current_value + 1
    {:reply, new_value, new_value}
  end
endCode language: Elixir (elixir)

Thread-safe ID generation prevents race conditions in concurrent environments.

Public API Layer

defmodule ParkingLot do
  @moduledoc """
  Public API for the parking lot system.
  """

def park(registration_no, color) do
    ParkingLot.Boundary.ParkingManager.park(registration_no, color)
  end
  def leave(slot_id) do
    ParkingLot.Boundary.ParkingManager.leave(slot_id)
  end
  def status do
    ParkingLot.Boundary.ParkingManager.status()
  end
  def registration_numbers_for_cars_with_color(color) do
    with {:ok, status} <- status() do
      numbers = 
        status.used_slots
        |> Enum.filter(fn slot -> 
          case ParkingLot.Boundary.TicketManager.get_ticket(slot.ticket_id) do
            %{vehicle: %{color: ^color}} -> true
            _ -> false
          end
        end)
        |> Enum.map(fn slot ->
          ticket = ParkingLot.Boundary.TicketManager.get_ticket(slot.ticket_id)
          ticket.vehicle.registration_no
        end)
      
      {:ok, numbers}
    end
  end
endCode language: Elixir (elixir)

Error Handling: Fail Fast, Fail Clear

The system uses a consistent error handling pattern:

# Success case
{:ok, slot_id} = ParkingLot.park("ABC-123", "RED")

# Error case  
{:error, "Sorry, parking lot is full"} = ParkingLot.park("XYZ-789", "BLUE")Code language: Elixir (elixir)

All public APIs return {:ok, result} or {:error, reason} tuples, making error handling explicit and predictable.

Testing Strategy

The test suite covers happy path scenarios, error conditions, edge cases, and fault tolerance:

defmodule ParkingLotTest do
  use ExUnit.Case

test "create parking lot and park vehicles" do
    vehicles = [
      %{registration_no: "KA-01-HH-1234", color: "White"},
      %{registration_no: "KA-01-HH-9999", color: "White"},
      %{registration_no: "KA-01-BB-0001", color: "Black"}
    ]
    Enum.each(vehicles, fn %{registration_no: registration_no, color: color} ->
      case ParkingLot.park(registration_no, color) do
        {:ok, _slot_id} -> :ok
        {:error, msg} -> refute "Sorry, parking lot is full" == msg
      end
    end)
  end
  test "parking manager recovers from crash" do
    {:ok, _slot_id} = ParkingLot.park("ABC-123", "RED")
    
    # Simulate crash
    Process.exit(Process.whereis(ParkingLot.Boundary.ParkingManager), :kill)
    
    # Give supervisor time to restart
    Process.sleep(100)
    
    # Should still work after restart
    assert {:ok, _slot_id} = ParkingLot.park("XYZ-789", "BLUE")
  end
  test "handles concurrent parking requests" do
    tasks = Enum.map(1..10, fn i ->
      Task.async(fn ->
        ParkingLot.park("REG-#{i}", "COLOR-#{i}")
      end)
    end)
    results = Enum.map(tasks, &Task.await/1)
    
    # All should succeed (assuming sufficient slots)
    assert Enum.all?(results, fn
      {:ok, _} -> true
      {:error, "Sorry, parking lot is full"} -> true
      _ -> false
    end)
  end
endCode language: Elixir (elixir)

Configuration Management

The system supports flexible configuration:# config/config.e

# config/config.exs
config :parking_lot,
  default_slot_count: 6,
  max_slot_count: 1000,
  min_slot_count: 1

# mix.exs
def application do
  [
    extra_applications: [:logger],
    mod: {ParkingLot.Application, []},
    env: [
      default_slot_count: 6,
      max_slot_count: 1000,
      min_slot_count: 1
    ]
  ]
endCode language: Elixir (elixir)

Why These Patterns Matter in Production

This architecture shines when you need:

Fault Tolerance
  • Process Isolation: A crash in ticket management doesn’t affect parking operations
  • Automatic Recovery: Supervisors restart failed processes automatically
  • State Protection: Immutable data prevents corruption
Scalability
  • Concurrent Operations: Multiple vehicles can be processed simultaneously
  • Resource Isolation: Each process has its own memory space
  • Easy Distribution: Processes can run on different nodes
Maintainability
  • Clear Boundaries: Each layer has a single responsibility
  • Testable Components: Each part can be tested in isolation
  • Debugging: Process names and message passing make issues traceable

When to Use This Architecture

This approach excels for:

  • Systems with complex business rules
  • Applications requiring high concurrency
  • Long-running processes that need fault tolerance
  • Systems that need to scale horizontally

Simpler approaches work better for:

  • Basic CRUD operations
  • Stateless web APIs
  • Prototype applications
  • Systems with simple business logic

Common Concerns Addressed

“Isn’t this over-engineering for a parking lot?”

While true for a simple implementation, this architecture becomes invaluable when you need to add features like:

  • Real-time updates to multiple clients
  • Payment processing integration
  • External API integrations
  • Audit trails and event sourcing
“How does this compare performance-wise?”

The process overhead is minimal for most use cases (processes in Elixir are lightweight), and the benefits of fault isolation typically outweigh the costs. For high-throughput scenarios, you can optimize by batching operations or using ETS for shared state.

“Is the complexity worth it?”

The upfront complexity pays dividends as the system grows. The clean boundaries make adding features predictable, and the fault tolerance prevents small issues from becoming system-wide outages.

Future Improvements

1. Persistence Layer
# Add Ecto schemas for persistence
defmodule ParkingLot.Repo.ParkingLot do
  use Ecto.Schema
  
  schema "parking_lots" do
    field :slot_count, :integer
    has_many :slots, ParkingLot.Repo.Slot
    timestamps()
  end
endCode language: Elixir (elixir)
2. Event Sourcing
defmodule ParkingLot.Events.VehicleParked do
  defstruct [:slot_id, :registration_no, :color, :timestamp]
end

defmodule ParkingLot.EventStore do
  use GenServer
  
  def append_event(event) do
    GenServer.call(__MODULE__, {:append, event})
  end
endCode language: Elixir (elixir)
3. Monitoring & Observability
# Add telemetry events
:telemetry.execute([:parking_lot, :vehicle, :parked], %{slot_id: slot_id}, %{
  registration_no: registration_no,
  color: color
})Code language: Elixir (elixir)
4. Dynamic Parking Lot Management
# Use DynamicSupervisor for multiple parking lots
DynamicSupervisor.start_child(
  ParkingLot.ParkingLotSupervisor,
  {ParkingLot.Boundary.ParkingManager, [slot_count: 100, name: :lot_2]}
)Code language: Elixir (elixir)
5. UI Layer
# Add Phoenix LiveView for real-time updates
defmodule ParkingLotWeb.ParkingLive do
  use ParkingLotWeb, :live_view

def mount(_params, _session, socket) do
    if connected?(socket) do
      ParkingLot.subscribe_to_updates()
    end
    
    {:ok, assign(socket, status: ParkingLot.status())}
  end
endCode language: Elixir (elixir)

Lessons Learned

1. Domain-First Thinking

Starting with the core domain concepts (ParkingLot, Slot, Vehicle) rather than database schemas led to cleaner, more maintainable code.

2. Process Boundaries Matter

Separating concerns into different processes made the system more resilient and easier to reason about.

3. Supervision Trees Provide Safety

Having a proper supervision strategy means the system can recover from failures automatically.

4. Immutable State is Powerful

Working with immutable data structures made debugging easier and eliminated entire classes of bugs.

5. The OTP Mental Model Shift

Moving from “schema → model → controller” to thinking in terms of cooperating processes opens up possibilities for natural concurrency, fault isolation, and horizontal scaling.

Conclusion

Building this parking lot system using OTP principles was an eye-opening experience. The clean separation of concerns, immutable state management, and process-based architecture resulted in a system that’s:

  • Testable: Each component can be tested in isolation
  • Maintainable: Clear boundaries make changes predictable
  • Scalable: Process-based architecture allows for horizontal scaling
  • Resilient: Fault isolation prevents cascading failures

While the traditional approach of “schema → model → controller” works for many applications, the OTP way of thinking about systems as collections of cooperating processes provides a more robust foundation for complex business logic.

The key insight from Bruce Tate’s book that really clicked was: “Let it crash” — design your system so that when things go wrong, individual processes can fail and restart without bringing down the entire system.

This parking lot system is just the beginning. The patterns learned here can be applied to much more complex domains, from e-commerce platforms to real-time trading systems. The investment in understanding these patterns pays dividends as your applications grow in complexity and scale.

What’s your experience with OTP patterns? Have you tried applying domain-driven design principles in your Elixir projects? I’d love to hear your thoughts and experiences!

Resources:

I hope you have enjoyed this long article. If you liked it, consider buying a coffee here … Thank You


Leave a Comment

Your email address will not be published. Required fields are marked *