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)
- User calls
ParkingLot.park("ABC-123", "RED") - Public API validates inputs and delegates to ParkingManager
- ParkingManager calls
CoreParkingLot.park/3 - Domain logic validates, creates ticket, updates state
- StateValidator ensures consistency
- 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 operationsTicketManager– manages ticket lifecycleAtomicCounter– 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:
- Designing Elixir Systems with OTP by Bruce Tate and James Gray
- Elixir School — OTP Supervisors
- The Pragmatic Bookshelf — Excellent resources for functional programming
I hope you have enjoyed this long article. If you liked it, consider buying a coffee here … Thank You

