The domain map
Getting to know a new system always starts with a question: what is it for, and what does it do. These are questions about meaning, not about construction. But the first answer is usually infrastructure — controllers, services, repositories, migrations. The problem domain is nowhere to be seen behind them.
The domain map is the answer to that first question. It carries nothing but the areas the system is made of: StoreDomain — order management, BillingDomain — billing, MessagingDomain — notifications. An answer without detail covers the whole system and fits in your head — the first foothold.
StoreDomainorder management
BillingDomainbilling
MessagingDomainnotifications
Entities express the domain
A domain holds two main things: Entities — order, customer, line — and Actions, the named operations over them. No controllers, no tables, no other infrastructure. That lets you go one step deeper while keeping the focus on the system's business intent rather than its technical implementation.
An Entity is an ordinary class: a name, typed fields, and nothing else. It knows nothing about an ORM, nothing about tables, nothing about where the data will come from — its only job is to describe a real-world object the way people talk about it: an order has a total and a currency, a customer has a name and an email. Fields come in two kinds: plain ones — string, number, date — and references to other Entities, because an order has a customer and it has lines. The second kind is what turns a set of separate classes into a model, and that is the next section. Behaviour is absent by design: an Entity only describes, and everything done to the object lives in an Action.
What an Action is →Relations make ownership explicit
What a domain is made of is clear by now. But what holds it together, and what keeps its relations from drifting apart?
A relation is described by two things: how tightly the objects hold on to each other — composition, aggregation, or association — and how many objects stand on each side, one or several. Every entity declares the relation in full: its own side and the facing one, with a type and a multiplicity for both. So looking at a single entity is enough to see all of its relations at once. At startup the system checks every pair — if the two sides disagree, the run stops. Sometimes there is no second side: the relation points beyond its own store. That is allowed too, but it has to be declared.
Lifecycle guards every transition
While the status is just a string, nothing stops an order from going straight from draft to delivered. What is allowed and what is not lives in the team's heads, or in a comment that went stale long ago.
In AOA those rules stop being spoken ones: an order's states and the transitions between them are declared next to the entity itself, as a state machine — draft, paid, shipped, delivered, and cancellation is possible only from the first two. This is not a recommendation: a transition that is not on the list does not exist, and an order cannot end up delivered without having been paid. The machine itself is checked at startup — a state you cannot reach, or a transition to nowhere, stops the run long before the first real order goes through.
The machine is declared on its own: which states an order can take, and which transitions between them are allowed. Once, apart from any entity.
Now it can be applied: on OrderEntity the status field stops being a string and takes the type OrderLifecycle. A string accepted any value; this type accepts only the ones there is a path to.
Resources define the storage boundary
The model describes the problem domain, but it does not work with databases directly: it must not depend on how they are built. So something else has to fetch the data from a real database — and put it back.
That is what a Resource is for. It holds everything that has to live between calls: the connection, the pool, the client. Its job is to open, execute, return; there are no business rules inside. Entities are what crosses that boundary. One and the same model can be represented by several Resources at once: one reads from SQL, another from NoSQL, a third from someone else's HTTP service, and all of them return the same OrderEntity. The business-logic code never learns which Resource ran: no table name, no dialect, no response format reaches it. So changing the store means changing the Resource and nothing else.
Projections shape every read
The same entity is read from the database sometimes whole, sometimes partially, sometimes as nothing but an identifier. The usual answer to that is a zoo of DTOs, one class per case.
The usual approach is a DTO of its own for every resource method. Unlike returning plain dictionaries, that gives static protection against typos in field names. But the approach has another side: the original entity falls apart into a crowd of small DTOs, and the domain model survives only in documentation and diagrams, which go stale before the first release.
AOA's idea is not to give up the whole model: a resource method returns the model's own objects, exactly as declared. Fields are read through named attributes, as on a DTO, but no new classes appear — and putting a field into the model that the model does not have becomes impossible. The model is kept current by the very code that uses it, and the ERD is drawn straight from it.
Hence the notion of a data projection: when a resource method reads an entity partially, it returns that same entity, but the fields not read from the database are marked unavailable. The first attempt to use one raises a clear exception instead of quietly passing.
The ERD model in Maxitor →get_order(order_id) → OrderEntity
The obvious case — the whole entity, every field loaded; the model class used exactly as declared.
find_orders(query) → list[OrderEntity]
A slim collection — only each order's id is loaded; reading any other field would raise.
get_full_order_info(order_id) → OrderEntity
The deep case — the order plus its nested entities, each loaded to a different partial depth (id is always loaded, it is the key).
