"I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning -- it took a while to see how to do messaging in a programming language efficiently enough to be useful)."
— Alan Kay
A library for creating stateful objects backed by GenServer processes with inheritance support.
Elixir's process isolation perfectly embodies Alan Kay's original vision of object-oriented programming. Each process is truly isolated with its own memory space, can only communicate through messages, and fails independently without affecting other processes. GenObject builds on this foundation to provide a clean, object-like interface over GenServer processes, making it easy to create stateful objects that communicate through message passing while maintaining the fault-tolerance and concurrency benefits of the Actor model.
GenObject provides a macro-based DSL for defining object-like structures that maintain state in GenServer processes. Objects support field access, updates, lazy operations, and merging. The library uses the Inherit library to provide powerful inheritance modeling capabilities.
GenObjects have significant performance overhead compared to using Elixir's native data structures directly. Each GenObject is backed by a GenServer process, which means every field access, update, or merge operation involves message passing and serialization. This is much slower than working with structs, maps, and other Elixir literals in memory.
GenObjects are best suited for specific patterns where you need long-lived stateful objects, process isolation, fault tolerance, concurrent access to shared state, or complex inheritance hierarchies.
Do not use GenObjects as a general replacement for Elixir's built-in data structures in your application. Use them judiciously where the benefits of process isolation and message passing outweigh the performance costs.
- Stateful Objects: Objects backed by GenServer processes with automatic lifecycle management
- Field Operations: get, set, and merge operations with both synchronous and asynchronous variants
- Lazy Operations: Functions that compute values based on current object state
- Inheritance Support: Inherit library for object inheritance patterns
- Process Safety: All operations are process-safe through GenServer messaging
- Performance: Asynchronous variants for high-performance scenarios
Add gen_object
to your list of dependencies in mix.exs
:
def deps do
[
{:gen_object, "~> 0.4.0"}
]
end
defmodule Person do
use GenObject, [
name: "",
age: nil,
email: nil
]
end
# Create a new person object
person = Person.new(name: "Alice", age: 30)
# Returns: %Person{name: "Alice", age: 30, email: nil, pid: #PID<...>}
# Get a single field (most efficient)
name = Person.get(person, :name)
# Returns: "Alice"
# Get multiple fields at once
[name, age] = Person.get(person, [:name, :age])
# Returns: ["Alice", 30]
# Can also use PID directly
age = Person.get(person.pid, :age)
# Returns: 30
# Get the complete object (when you need all fields)
current_person = Person.get(person)
# Returns: %Person{name: "Alice", age: 30, email: nil, pid: #PID<...>}
# Synchronous update (returns updated object)
person = Person.set(person, :age, 31)
person = Person.set(person.pid, :email, "[email protected]")
# Asynchronous update (returns :ok immediately)
:ok = Person.set!(person, :age, 32)
:ok = Person.set!(person.pid, :name, "Alice Smith")
# Verify async updates
age = Person.get(person, :age)
name = Person.get(person, :name)
Lazy updates allow you to compute new values based on the current object state:
# Increment age based on current value
person = Person.set_lazy(person, :age, fn p -> p.age + 1 end)
# Create display name from existing fields
person = Person.set_lazy(person, :display_name, fn p ->
"#{p.name} (#{p.age})"
end)
# Asynchronous lazy update
:ok = Person.set_lazy!(person, :age, fn p -> p.age + 1 end)
# Synchronous merge
person = Person.merge(person, %{
name: "Alice Johnson",
age: 35,
email: "[email protected]",
location: "San Francisco"
})
# Asynchronous merge
:ok = Person.merge!(person, %{age: 36, location: "New York"})
# Lazy merge based on current state
person = Person.merge_lazy(person, fn p ->
age_group = if p.age < 18, do: "minor", else: "adult"
%{
age_group: age_group,
can_vote: p.age >= 18,
display_name: "#{p.name} (#{age_group})"
}
end)
GenObject uses the Inherit library to provide powerful inheritance modeling:
defmodule Animal do
use GenObject, [
name: "",
species: "",
age: 0
]
def speak(%__MODULE__{} = animal) do
"#{animal.name} makes a sound"
end
end
defmodule Dog do
use Animal, [
breed: "",
trained: false
]
# Override parent method
def speak(%__MODULE__{} = dog) do
"#{dog.name} barks! Woof!"
end
def sit(%__MODULE__{trained: true} = dog) do
Dog.set(dog, :position, :sitting)
end
def sit(%__MODULE__{trained: false} = dog) do
{:error, "#{dog.name} is not trained to sit"}
end
end
# Dog inherits all fields from Animal plus its own
dog = Dog.new(
name: "Rex",
species: "Canis lupus",
breed: "Labrador",
age: 3,
trained: true
)
# Use inherited and own methods
Dog.speak(dog) # "Rex barks! Woof!"
Dog.sit(dog) # Updates position to :sitting
defmodule LivingThing do
use GenObject, [
alive: true,
birth_date: nil
]
end
defmodule Animal do
use LivingThing, [
name: "",
species: ""
]
end
defmodule Mammal do
use Animal, [
warm_blooded: true,
fur_color: nil
]
end
defmodule Dog do
use Mammal, [
breed: "",
trained: false
]
end
# Dog inherits from the entire chain
dog = Dog.new(
name: "Buddy",
species: "Canis lupus",
breed: "Golden Retriever",
fur_color: "golden",
birth_date: ~D[2020-01-15],
trained: true
)
defmodule Vehicle do
use GenObject, [
make: "",
model: "",
year: nil,
mileage: 0
]
def drive(%__MODULE__{} = vehicle, distance) do
Vehicle.set_lazy(vehicle, :mileage, fn v -> v.mileage + distance end)
end
end
defmodule Car do
use Vehicle, [
doors: 4,
fuel_type: :gasoline
]
def honk(%__MODULE__{} = car) do
"#{car.make} #{car.model} honks: BEEP BEEP!"
end
end
defmodule ElectricCar do
use Car, [
battery_capacity: 0,
charge_level: 100,
fuel_type: :electric # Override parent default
]
def charge(%__MODULE__{} = car, amount) do
ElectricCar.set_lazy(car, :charge_level, fn c ->
min(100, c.charge_level + amount)
end)
end
# Override parent method
def drive(%__MODULE__{} = car, distance) do
car = super(car, distance) # Call parent implementation
# Reduce charge based on distance
ElectricCar.set_lazy(car, :charge_level, fn c ->
max(0, c.charge_level - div(distance, 10))
end)
end
end
# Create an electric car with full inheritance chain
tesla = ElectricCar.new(
make: "Tesla",
model: "Model 3",
year: 2023,
battery_capacity: 75,
doors: 4
)
# Use methods from all levels of inheritance
tesla = ElectricCar.drive(tesla, 100) # Inherited and overridden
tesla = ElectricCar.charge(tesla, 20) # Own method
message = Car.honk(tesla) # Parent method
GenObject supports virtual attributes - computed fields that don't store data directly but calculate values dynamically or transform input into multiple real fields. Virtual attributes are implemented by overriding the handle_get/2
, handle_set/2
, and handle_merge/2
callbacks.
Virtual attributes that calculate values from existing fields:
defmodule Person do
use GenObject, [
first_name: "",
last_name: "",
age: nil
]
# Virtual attribute that computes full name from parts
def handle_get(:name, %Person{} = person) do
"#{person.first_name} #{person.last_name}"
end
# Fall back to default behavior for regular fields
def handle_get(field, object) do
super(field, object)
end
end
person = Person.new(first_name: "Alice", last_name: "Smith")
Person.get(person, :name) # Returns: "Alice Smith"
# Get multiple fields including virtual attributes
[name, first_name, last_name] = Person.get(person, [:name, :first_name, :last_name])
# Returns: ["Alice Smith", "Alice", "Smith"]
Virtual attributes that parse input and set multiple real fields:
defmodule Person do
use GenObject, [
first_name: "",
last_name: "",
age: nil
]
# Virtual attribute that splits full name into parts
def handle_set({:name, full_name}, %Person{} = person) do
[first_name, last_name] = String.split(full_name, " ", parts: 2)
Map.merge(person, %{first_name: first_name, last_name: last_name})
end
# Fall back to default behavior for other operations
def handle_set(pair, object), do: super(pair, object)
# Computed virtual attribute (from above)
def handle_get(:name, %Person{} = person) do
"#{person.first_name} #{person.last_name}"
end
# Fall back to default behavior for other operations
def handle_get(field, object), do: super(field, object)
end
person = Person.new()
person = Person.set(person, :name, "Bob Johnson")
[first_name, last_name, name] = Person.get(person, [:first_name, :last_name, :name])
# Returns: ["Bob", "Johnson", "Bob Johnson"]
# Virtual attributes work with merge operations too
person = Person.merge(person, %{name: "Carol Davis", age: 25})
[first_name, last_name] = Person.get(person, [:first_name, :last_name])
# Returns: ["Carol", "Davis"]
You can create more complex virtual attributes for validation, formatting, or derived data:
defmodule BankAccount do
use GenObject, [
balance_cents: 0,
account_number: "",
routing_number: ""
]
# Virtual attribute for balance in dollars
def handle_get(:balance, %BankAccount{} = account) do
account.balance_cents / 100
end
# Virtual attribute for formatted account info
def handle_get(:account_info, %BankAccount{} = account) do
"Account: #{account.account_number} (Routing: #{account.routing_number})"
end
def handle_get(field, object), do: super(field, object)
def handle_set({:balance, dollars}, %BankAccount{} = account) do
cents = round(dollars * 100)
Map.put(account, :balance_cents, cents)
end
# Validation virtual attribute
def handle_set({:account_details, details}, %BankAccount{} = account) do
%{account_number: acct, routing_number: routing} = details
unless valid_account_number?(acct) do
raise ArgumentError, "Invalid account number"
end
Map.merge(account, %{account_number: acct, routing_number: routing})
end
def handle_set(pair, object), do: super(pair, object)
defp valid_account_number?(number) do
String.length(number) >= 8
end
end
account = BankAccount.new(balance_cents: 10050)
balance = BankAccount.get(account, :balance) # Returns: 100.5
account = BankAccount.set(account, :balance, 250.75)
[balance, balance_cents] = BankAccount.get(account, [:balance, :balance_cents])
# Returns: [250.75, 25075]
You can override GenServer callbacks while still using GenObject functionality:
defmodule TimestampedObject do
use GenObject, [
data: nil,
created_at: nil,
updated_at: nil
]
# Override init to set timestamps
def init(opts) do
now = DateTime.utc_now()
opts = opts
|> Keyword.set(:created_at, now)
|> Keyword.set(:updated_at, now)
super(opts) # Call GenObject's init
end
# Override handle_call to update timestamps
def handle_call({:set, field, value}, from, object) do
result = super({:set, field, value}, from, object)
case result do
{:reply, updated_object, state} ->
updated_state = TimestampedObject.set(state, :updated_at, DateTime.utc_now())
{:reply, updated_object, updated_state}
other -> other
end
end
end
GenObjects can be supervised like any GenServer:
defmodule MyApp.ObjectSupervisor do
use Supervisor
def start_link(_opts) do
Supervisor.start_link(__MODULE__, [], name: __MODULE__)
end
def init([]) do
children = [
{Person, [name: "Default Person"]},
{Dog, [name: "Default Dog", breed: "Mixed"]}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
- Use asynchronous operations (
set!/3
,merge!/2
, etc.) when you don't need the return value - Use
get/2
for single fields instead ofget/1
when you only need one field - Batch multiple updates using
merge/2
instead of multipleset/3
calls - Lazy operations are computed synchronously, so complex computations may block
YourModule.new/1
- Create a new objectYourModule.close/1
- Stop the object process
YourModule.get/1
- Get complete object stateYourModule.get/2
- Get specific field value or multiple field values
YourModule.set/3
- Update field synchronouslyYourModule.set!/3
- Update field asynchronouslyYourModule.set_lazy/3
- Update field with function synchronouslyYourModule.set_lazy!/3
- Update field with function asynchronously
YourModule.merge/2
- Merge multiple fields synchronouslyYourModule.merge!/2
- Merge multiple fields asynchronouslyYourModule.merge_lazy/2
- Merge with function synchronouslyYourModule.merge_lazy!/2
- Merge with function asynchronously
All functions accept either a PID or an object struct containing a :pid
field. Replace YourModule
with your actual module name (e.g., Person
, Dog
, etc.).
- Fork the repository
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request
This project is licensed under the MIT License - see the LICENSE.md file for details.