Contents
Chapter 0
The problem: design and code drift apart
What this chapter is about
Why reading the Domain-Driven Design book is not enough, and why what we designed and what we built drift apart in almost every large system. D3I was made for this problem.
A familiar story
When a new system starts, everyone knows exactly what is for what. There is a nice architecture diagram, a wiki page about the concepts, and the team means the same thing by “order” and “customer”. Two years later the diagram is no longer true. Nobody updates the wiki. The “order” lives in three places with three different sets of fields, and only someone who was there at the beginning can reconstruct the business rules from the code.
I don’t think the cause is laziness or bad developers. The cause is structural: the design lives in documents, the implementation in source code, and between the two there is manual transcription. Whatever is synchronized by hand drifts sooner or later, and the drift stays invisible until it causes trouble.
The usual answer is to generate documentation from the code. That helps, but documentation generated from code is technical: it is about classes and methods, not about customers and orders. A business analyst or a domain expert can do little with it.
Let’s reverse the direction
D3I rests on a simple idea: the description should not be made from the code; the code should be made from the description. We describe the system in a language designed for this, with business concepts: domain, context, aggregate, event, contract. From this description we get the backend skeleton, the contracts between services, the client-side types and a description meant for machine processing.
So the model is the definition of the system. When the model changes, the generated code changes too. When the hand-written business logic no longer fits the new model, the project does not compile, and the drift shows up at the next build.
← scroll the diagram sideways →
Why DDD
In 2003 Eric Evans’s book Domain-Driven Design gave names to what good designers had been doing by instinct. Its central claim is that in the long run the quality of software depends less on technology than on how well it understands and how faithfully it maps the business domain. Evans introduced the shared language (ubiquitous language) spoken by business and development alike, and the building blocks: bounded context, aggregate, entity, value object, event.
DDD makes sense on paper. Applying it consistently within a team, for years, with people coming and going, is close to impossible. The rules live in comments and code reviews, and on a tired Friday afternoon an exception slips through. D3I puts these rules into the language. What is in the language is checked by the compiler, not by the reviewer.
Chapter 1
What D3I is, and where it fits
What this chapter is about
The parts of D3I: the language, the interpreter, the linters, the emitters and the decorators. We look at how a .d3 file becomes code, and what happens when the model is wrong.
A language and a compiler
The name D3I is short for DDD Interpreter. It means two things at once. First, a domain-specific language of its own, whose keywords are the concepts of DDD: domain, context, aggregate, entity, valueobject, event, interface, workflow. These are not annotations or naming conventions but grammatical elements. The compiler knows the difference between an aggregate and a value object, because the grammar tells it.
Second, an interpreter written in Python that processes the language. It runs at development time, like a compiler: nothing of it is linked into your program, and it does not take control at runtime.
Part of MicronIQ
D3I is the first stage of a larger whole, the MicronIQ framework. MicronIQ is built for cloud backends where thousands of users and events show up at once, and the system has to stay maintainable for years. Its parts build on each other:
- D3I: design. Everything starts here.
- UniContract: generates consistent client code for several platforms from a single interface definition.
- PolyPersist: a uniform interface to different data stores, so the domain logic is not tied to one database.
- SrvKit: the common foundation of the services: communication, error handling, call context, HTTP and gRPC in a uniform way.
- Orbit: deployment. An idempotent deployer that manages the Kubernetes environment, the Helm charts and the services.
This piece is about D3I. I mention the other parts where the generated code relies on them.
The steps of a run
A run has four steps. The ANTLR 4-based parser reads the .d3 files and builds a syntax tree from them. This becomes a semantic model in which names are already resolved: we know which aggregate in which context the Customer reference points to. Linters check the semantic model. Finally, the emitters produce output from the model.
← scroll the diagram sideways →
The linters run before the emitters, and if they find an error, the process stops. A faulty, contradictory model produces neither code nor documentation. This is the fail-fast principle: an error should surface where it is cheapest to fix.
Linters, emitters, decorators
Two built-in linters always run: the syntactic and the semantic one. The semantic check enforces the rules of DDD: for example, that no entity may appear on an interface, or that a new version of an event may not drop a field. On top of these you can write your own linters in Python, with project- or company-specific rules.
The emitter decides what output is produced. Today there are four: dotnet:backend (a C# service skeleton), typescript:client (types, REST client and validators), proto (gRPC contract) and json (the whole model in machine form). A Java emitter is planned. You can write your own emitter too, without modifying the core.
A decorator is a marker starting with @ on an element of the model. It is not a new business concept but additional information for the linters and emitters. @public_api(rest, collection="BFF") says what technology an interface should be built on and for whom. @optional makes a field optional, @gdpr marks personal data, @deprecated marks something obsolete. You can introduce your own decorators, and your own linter or emitter can interpret them.
Running it is a single command:
python cli.py -p WebShop -i webshop.d3 \
-e dotnet:backend typescript:client proto json \
-o ./generated
D3I also comes with a VS Code extension: a Python language server flags errors while you type, with the same rules as the compiler.
Chapter 2
The map: domain and context
What this chapter is about
The top two levels of the model: the domain as a business area, the context as a unit of responsibility and deployment. This is where it is decided how many microservices the system will consist of.
The example: a web shop
The profession has long used a web shop to illustrate DDD, because everyone understands how one works. I use it too, but not with made-up details: the examples come from the real model in the public samplewebshop repository. The model is 375 lines in four files.
Domain: a business area, not a module
The domain is the highest-level organizing element. An application has few of them, and each one marks off a major business area: sales, customer management, payments, shipping, access management. A domain is not a technical module. It is a business grouping: it shows where the natural boundaries of business knowledge lie.
In a small web shop these boundaries blur, since one person can see the whole thing. In a bank, however, lending, card management, account keeping and investment are separate worlds, with separate experts and decision makers. There the domain boundary maps this business reality. D3I recommends using domains even in a small system. It means somewhat more constrained work, which in my experience pays off in debugging, independent extension and safer refactoring.
Context: one responsibility, one microservice
In the language, a bounded context is simply a context. It is a boundary inside a domain within which one model and one language apply. A domain typically has several contexts. If a domain has only one, it is worth asking whether it really is a separate domain.
D3I also treats the context as a microservice boundary. Whatever belongs to a context (aggregate, service, interface, event handler, workflow) goes into the same generated service. So a context is a complete unit of responsibility, with its own data, processes, contracts and integrations. In a smaller system several contexts can be deployed together, but the boundary remains in the model.
domain Sales {
context OrderManagement {
aggregate Order { ... }
service OrderService { ... }
workflow FulfilOrder { ... }
interface OrderIF version 1 { ... }
interface OrderIF version 2 { ... }
}
context Tracking {
eventhandler onOrderPlaced for integration event OrderManagement.OrderIF#1.OrderPlaced#1
aggregate OrderTrackingEntry { ... }
}
}
← scroll the diagram sideways →
Contexts do not call into each other’s internals. They talk to each other only through versioned interfaces and events. Tracking does not know how OrderManagement stores an order, only what OrderManagement announces about itself. Within a single backend this may look like overkill, but it is what lets you rewrite one context without the others noticing.
Conway’s law
In 1967 Melvin Conway observed that the structure of a system copies the communication structure of the organization that builds it. You can design with this law or against it. D3I designs with it: the domain and the context are designed to be natural team and responsibility boundaries. One team owns one context, and its changes stay there.
Chapter 3
The building blocks
What this chapter is about
The elements inside a context: value object, composite, enumeration, entity, aggregate, and references between aggregates. This is where it is decided what belongs together, and where the consistency boundaries lie.
Value object: a value with no identity
A value object has no identifier. Two addresses are the same address if all their fields are equal. A value object does not change: if a different value is needed, a new instance is created. This rules out many state-management bugs from the start.
A value object keeps a business concept from falling apart into primitive types. A postal address is an address, not four strings, and an amount of money is a number and a currency together. When the model says so, the value is hard to misuse.
valueobject PostalAddress {
country:string validate len(value) == 2
postalCode:string validate len(value) > 0
city:string validate len(value) > 0
street:string validate len(value) > 0
}
validate is a small expression language for the business rules of fields. It knows comparison, logical operators, range and set checks, and a few functions such as len and matches. It can refer to the field’s own value and to sibling fields, so the relation between a start date and an end date can be expressed too. D3I type-checks it: you cannot write “greater than” on a string. The rule becomes readable validation code on the server, and where it makes sense, a validator is generated next to the client-side types as well. Client-side validation serves the user experience; the server always checks again.
Composite and enumeration
A composite is a reusable, named group of fields. On its own it is not a business concept and has no identity. Entities and value objects inherit it, and its fields are built into the receiving type. In the sample web shop every stored entity inherits the BaseEntity field group of the Core domain:
composite BaseEntity {
@partitionKey
@system_field
id:string
@system_field
etag:string
@system_field
LastUpdate:datetime
}
@system_field signals that these fields belong to the infrastructure, not to the business. PolyPersist needs them, but the emitter leaves them out of the business surface.
An enumeration is a closed set of values: an order’s status is Draft, Released, Finished or Canceled, and nothing else. It replaces “magic numbers” and scattered constants. If an enum also appears in communication, D3I generates the .proto definition and the mapping for it too.
Entity: identifier and partition key
An entity has an identifier, and its state changes over its lifetime. A customer is the same customer even after moving to a new address. Entities often have a partition key as well, and the two are different things. The identifier tells you which entity it is. The partition key tells you where it belongs: which logical or physical group it goes into in the store. For an order this is often the customer’s identifier, for stock it is the warehouse’s.
Under heavy load this is what lets data that belongs together be queried and scaled together. In D3I this decision is visible in the model, not deep in the implementation: @partitionKey sits right above the field.
Aggregate: the boundary of consistency
An aggregate is a unit of entities and value objects that belong together and must always be handled together from a business point of view. It has a single entry point, the aggregate root (root entity). Every change goes through it, so the root can enforce the business rules. For example, that an order’s total cannot be negative, or that no item can be added to a closed order.
aggregate Order {
enum OrderStatuses { Draft, Released, Finished, Canceled }
valueobject OrderItem {
productId:string
productName:string
quantity:number validate value > 0
unitPrice:number validate value >= 0
subTotalPrice:number validate value >= 0
}
root entity OrderHeader inherits Core.Base.BaseEntity, SalesDocument {
# Another aggregate is referenced only by identifier, never embedded.
customer:ref CustomerManagement.Customers.Customer
orderingDate:date
status:OrderStatuses
totalPrice:number validate value >= 0
items:list[OrderItem]
command place( customerId:string ) emits OrderPlaced
}
}
The size of an aggregate is a design decision. An aggregate that is too large is slow and hard to scale. One that is too small cannot protect the business rules. D3I does not decide for you, but it forces you to decide in the model, before the code.
References between aggregates: ref
An aggregate cannot contain another aggregate. If we embedded the customer in the order, the lives of the two aggregates would get tangled: who is responsible for the customer’s data, and what happens if the customer changes while the order is being saved? Instead, the order refers to the customer by identifier, and the ref keyword expresses this.
← scroll the diagram sideways →
ref states the intent, and the linter can check that it really points to an existing aggregate, not to a value object or to an entity inside an aggregate. In the generated code the reference becomes a typed identifier instead of a raw string.
// generated C#
public EntityId<CustomerManagement.Customers.Customer.CustomerAccount> customer { get; set; }
Because of this, a customer identifier and an order identifier differ at the type level. If someone accidentally passes an order ID where a customer ID is needed, it is a compile error instead of a production bug at dawn. On the wire, in the gRPC and REST contracts, the reference travels as a plain string identifier.
Chapter 4
The boundary: service, interface, DTO
What this chapter is about
The internal language of a context and the contract it promises to the outside are two different things. D3I does not let the domain model leak out through this boundary.
Service: the internal language
A service carries the business operations that do not fit any single entity: they touch several aggregates or drive a process. A service is always part of a context and is reachable only from within it. A microservice can have several services, each with its own job.
The surface of a service holds domain elements: entity, value object, aggregate, enum, primitive type. No DTOs. A service speaks the language of the domain.
service OrderService {
query getOrder( orderId:string ) : Order.OrderHeader
command placeOrder( order:Order.OrderHeader ) : Order.OrderHeader
}
A command changes state, a query does not. Keeping them apart is the basis for optimizing the read side and the write side separately (CQRS). The view helps here: a read-optimized projection that can gather data from several sources without violating aggregate boundaries.
Interface: the contract
The interface is the outward-facing boundary of a context: other contexts, other domains, the user interface or an external system reach it through here. Three rules apply to it, and the linter checks all three:
- A version is mandatory. An interface is a promise to its consumers, and the version says what it promises.
- Only DTOs, enums and primitive types may appear on its surface. Never an entity, value object or aggregate.
- A DTO can only be defined inside an interface. A DTO is a communication data structure, not part of the domain.
@internal_api(grpc, rest)
@public_api(grpc, rest, collection="BFF")
interface OrderIF version 1 {
dto OrderItemDTO {
productId:string
productName:string
quantity:number validate value > 0
unitPrice:number validate value >= 0
subTotalPrice:number
}
dto OrderDTO {
dto CustomerDataDTO {
customerId:string
customerName:string
}
@system_field id:string
orderingDate:string
orderStatus:OrderStatuses
totalPrice:number
customerData:CustomerDataDTO
items:list[OrderItemDTO]
}
query getOrder( orderId:string ) : OrderDTO
command placeOrder( order:OrderDTO ) : OrderDTO
}
The decorators say how the interface can be reached: internally over gRPC and REST, externally in the BFF collection too. The controller, the .proto, the internal client and the frontend’s TypeScript client are all made from the same definition.
Why sending out the entity is not enough
It would be convenient to simply return the OrderHeader entity on the interface. Two years later that convenience ties your hands. If the internal model is also the public contract, the internal model cannot change without breaking some external consumer. The DTO cuts this coupling. It contains what the given situation needs. The same aggregate can have several DTOs: a short summary for a list, a detailed one for an edit page.
If someone still put an entity on the interface, d3i stops them. This is the linter’s actual message:
Sales.d3(143,47): Severity.Error : An interface may only expose dto, enum and
primitive types on its surface; 'Order.OrderHeader' is a entity.
Map it to a dto for the wire contract.
abort on error is enabled, process is aborted
← scroll the diagram sideways →
Repository and ACL
The repository is the boundary between persistence and the domain. It saves and loads aggregates, always through the root, and is reachable only within its own context. Its surface holds only domain elements. Its implementation is built on PolyPersist, so the domain does not know, and does not need to know, whether it saves to SQL, a document store or an event store. Since it sits behind an interface, it is easy to substitute in tests.
The anti-corruption layer (ACL) protects against external systems. If the web shop connects to a courier company’s system, that system’s status codes and concepts must not get directly into our own Shipping domain. The ACL translates them into our own language. It is a deliberate translation point and contains no business logic. It is the one place that allows something D3I forbids elsewhere: the any type, because this is where we meet foreign data that is not yet typed.
Chapter 5
Events
What this chapter is about
An event is a fact in the past, not a command. D3I distinguishes the internal domain event, the public integration event and the audit record, because they solve different problems.
A fact, not a request
An RPC call has an addressee and expects an answer. An event has no addressee and expects nothing. It says “this happened”, and whoever is interested reacts. The emitter does not know who is listening, and does not need to. This is what loosens the coupling: a new consumer can come along without touching the emitter.
The structure of an event is strict. It can contain only primitive fields and enums, not entities or value objects, and it has no behavior.
Who records it, who sends it
In the sample web shop the order’s root declares that the place command produces an event:
command place( customerId:string ) emits OrderPlaced
The roles are separate. The root records the fact, because it enforces the rule that makes the fact true. The repository puts it into an outbox together with the save, in the same transaction. Delivery is the platform’s job. So it cannot happen that the order is saved but for some reason nobody receives the event, or the other way round. The generated code explains this too:
/// Writes down that 'OrderPlaced' happened. It is NOT sent here: the repository moves it
/// into the outbox inside the same save, and delivery is the platform's problem from there.
protected void Record( OrderPlaced @event )
=> _recordedEvents.Add( new RecordedEvent( @event, id ) );
Internal fact and public contract
A domain event speaks the internal language of the context. It has no version, and that is intentional: its consumers are in the same deployment unit and change together with it, so if its structure breaks, the result is a compile error, not a production incident.
domain event OrderPlaced {
orderId:string
customerId:string
totalPrice:number
}
An integration event is the public statement of the same fact, inside an interface, with a version. from says which internal fact it is made from. The translation has to be written by hand, on purpose, because the public language differs from the internal one. Here, for example, customerId stays inside, and the amount gets the name the outside world uses for it.
interface OrderIF version 1 {
integration event OrderPlaced version 1 from Order.OrderPlaced {
orderId:string
totalAmount:number
}
...
}
The consumer subscribes to the public event, down to the exact version. This is how the Tracking context reacts to an order being placed:
context Tracking {
eventhandler onOrderPlaced for integration event OrderManagement.OrderIF#1.OrderPlaced#1
...
}
Thanks to this, OrderManagement can rename an internal field tomorrow and Tracking will not even notice. The handler belongs to the context, not to a service, because it is the context that reacts. Which class runs it is an implementation detail.
← scroll the diagram sideways →
Audit: evidence, not an event
The third kind is a login, a password change, a change of permissions: who did what and when. In my earlier designs this was an event type too. In the language it ended up as audit record, because an audit is not an event. Nobody reacts to it, it starts no process; we only keep it as evidence. If we called it an event, we would promise behavior that does not exist. Whether the model still tells the truth years later depends on small naming decisions like this.
In storage, however, the two are the same: immutable, append-only entries in chronological order. That is why the business event stream and the audit live in the same event store, without a separate audit database. If an aggregate is marked eventsourced, its current state is not even stored directly. It is assembled from its events and can be replayed at any time.
Chapter 6
Versioning: what you once promised
What this chapter is about
A public interface or event is a promise. D3I does not just ask for a version number; it also checks whether the version keeps what it promises.
Two versions side by side
One of the worst bugs in event-driven systems is the silent break. The emitter changes the structure of a message, the consumer does not know about it and processes wrong data, without any error message. In most places this is handled with a wiki page and good intentions.
In D3I a new version of an interface is added next to the previous one, not in its place. In the sample web shop OrderIF version 1 and version 2 live at the same time. Old consumers can stay on v1, new ones can move to v2. In the generated code each has its own class, controller and .proto file: OrderIF_v1, OrderIF_v2.
The declaration uses a readable form (version 2), the reference a compact one: OrderIF#1.OrderPlaced#1. The compact form is there so that every reference shows which version it counts on.
The version number is a promise
An example from the history of the sample web shop: the first draft of the v2 integration event wanted to drop the totalAmount field and publish customerId instead. The evolution linter rejected it:
Sales.d3(154,12): Severity.Error : 'OrderPlaced' version 2 drops the field
'totalAmount', which version 1 has. A higher version is the same fact restated,
so what it used to say has to survive; if the field really is gone, this is a
different fact and deserves its own name.
The rule is simple. A higher version restates the same fact, so it may say more, but it may not say less. Whoever moves from v1 to v2 must not lose anything they used to receive. If a field really goes away, that is a different fact, and it deserves a different name.
← scroll the diagram sideways →
In the end v2 kept both fields and added a third. So two public contracts are made from the same internal Order.OrderPlaced fact, with two separate hand-written translations. In practice this means an interface can be carried forward without breaking the old callers.
Chapter 7
Long-running processes: workflow
What this chapter is about
Some business processes do not fit into a transaction: they take days, can fail halfway, and what has already been done must be undone. In D3I they are separate model elements: workflows.
When a function is no longer enough
In a starter web shop, closing an order is a status update. Then stock reservation arrives. Then card payment, invoicing, shipping preparation, notifying an external courier company. These do not happen at the same time, and any of them can fail. Without a workflow, two bad options remain. One is that the whole thing goes into a single, ever-growing service function. The other is that it falls apart into a loose chain of events that nobody sees as a whole. Both fail at the point where a step breaks halfway and the earlier ones have to be undone.
User registration is the same thing in miniature: email verification, activation link, password setup, basic permissions. Hours may pass between steps, and the process has to continue exactly where it left off.
Steps and compensations
A D3I workflow describes what steps there are and what undoes each one. The order is deliberately not written in the model, because a model can honestly state only the set of steps. The developer writes the order. The generated skeleton records the compensation of every executed step and, in case of failure, runs them in reverse order.
@retry( 3 )
@timeout( "10m" )
workflow FulfilOrder {
@start
command fulfil( order:ref Order, sku:string, quantity:number,
amount:number, shippingAddress:string ) : string
# No return value, so it is a signal: the caller gets no answer.
command cancel( reason:string )
# A query only reads, and it is synchronous: it may not wait for anything.
query status() : string
step reserveStock( order:ref Order, sku:string, quantity:number ) : string compensate releaseStock
step releaseStock( order:ref Order, sku:string, reservationId:string )
# Charging the card is not idempotent: a single attempt, a short deadline.
@retry( 1 )
@timeout( "30s" )
step chargeCard( order:ref Order, amount:number ) : string compensate refundCard
step refundCard( order:ref Order, chargeId:string )
# Nothing undoes shipping: what has gone has gone.
step shipOrder( order:ref Order, shippingAddress:string ) : string
}
These few lines hold several business decisions, all visible in the model. Charging the card is not idempotent, so we try only once, and its deadline is short so that the money does not get stuck in an uncertain state. Shipping has no compensation, and that absence is itself a statement. These are business properties, so they belong in the model.
← scroll the diagram sideways →
What runs underneath
Temporal runs behind the workflow: it stores the state of long-running processes durably, continues after a restart, and lets you see where a process is. D3I generates Temporal activities from the steps. Their interface is what you implement:
public partial interface IFulfilOrderActivities
{
[Activity]
Task<string> reserveStock(EntityId<Order.OrderHeader> order, string sku, decimal quantity);
[Activity]
Task releaseStock(EntityId<Order.OrderHeader> order, string sku, string reservationId);
[Activity]
Task<string> chargeCard(EntityId<Order.OrderHeader> order, decimal amount);
...
}
Workflows are also separated by scope. Some stay within one context, some coordinate several contexts within a domain, and some span domains. Each runs as a separate service in Kubernetes, so its scaling and monitoring follow its own load.
Chapter 8
What you get from the generator
What this chapter is about
The sample web shop is 375 lines of model. It becomes 54 files and roughly six thousand lines of code. We look at what d3i writes, what you have to write, and how the compiler holds the two together.
The structure of the generated service
Every context becomes a microservice with a uniform structure. Once you know your way around one, you are at home in all of them:
- Interfaces: the public, versioned contracts, the DTOs, the gRPC mapping and the
.protofiles. - Context: the core of the context. The internal service and ACL interfaces, the gRPC and REST controllers, the skeletons of event handlers and workflows, and the implementations where the real work happens.
- InternalClient: typed clients with which other contexts can call this service inside the system. The client asks for a service, not an address.
- Models: the generated code of entities, value objects and enums. We do not modify it by hand, because it is regenerated.
- Services: what is needed for startup, configuration and deployment, built on SrvKit.
The boundary between generated and hand-written code
The central design decision is where this boundary lies. d3i writes all the code that follows unambiguously from the model. You write what is a business decision. In C# the two are joined by partial classes and methods: the generated half declares what is needed, your half provides the body.
/// Declared by the model; the body is yours. Without it this does not compile - it does
/// not quietly become a command that is merely never there.
public partial void place(string customerId);
This is what I mean by a safety net. If a new command, event handler or translation is added to the model and you do not write its body, the project does not compile, instead of silently doing nothing. The same holds the other way round: if something is removed from the model, the code you wrote for it reports an error. The model and the code cannot drift apart, because the compiler checks the contract between them.
← scroll the diagram sideways →
A few generated excerpts
A value object is five lines in the model. The generated C# class contains the cloning, comparison and hashing needed for value semantics, and readable validation from the validate rules:
public virtual void ValidateInto( IList<IValidationError> errors, string pathPrefix )
{
if (quantity <= 0)
errors.Add( new ValidationError { TypeOfEntity = "OrderItem", MemberOfEntity = "quantity",
Path = pathPrefix + "quantity", ErrorText = "quantity must satisfy: value > 0" } );
if (unitPrice < 0)
errors.Add( new ValidationError { TypeOfEntity = "OrderItem", MemberOfEntity = "unitPrice",
Path = pathPrefix + "unitPrice", ErrorText = "unitPrice must satisfy: value >= 0" } );
}
The error has a path (items[1].quantity), so a form can bind it to the right field instead of guessing from text. The same rule also appears in the frontend’s TypeScript code:
export function validateOrderItemDTOInto( dto: OrderItemDTO, pathPrefix: string,
errors: ValidationError[] ): void {
if (Number(dto.quantity) <= 0)
errors.push({ typeOfEntity: "OrderItemDTO", memberOfEntity: "quantity",
path: pathPrefix + "quantity", errorText: "quantity must satisfy: value > 0" });
...
}
A gRPC contract is made from the interface as well, with a uniform response envelope: status, error list and the result.
service OrderIF_v1 {
rpc getOrder(OrderIF_v1_getOrderRequest) returns (OrderIF_v1_getOrderResponse);
rpc placeOrder(OrderIF_v1_placeOrderRequest) returns (OrderIF_v1_placeOrderResponse);
...
}
message OrderIF_v1_getOrderResponse {
ServiceKit.Protos.Statuses Status = 1;
repeated ServiceKit.Protos.Error Errors = 2;
oneof result {
OrderDTO Value = 3;
}
}
Documentation lines starting with # in the model go all the way through the generated code. What was an explanation in the model becomes XML documentation in C# and a comment in the .proto.
Testability
The generated structure is built on interfaces and dependency injection. Services, repositories and external connections are all behind interfaces, so the business logic can be tested in isolation, without a database or network.
Chapter 9
The thinking behind it
What this chapter is about
Why microservices, why events, why so many kinds of stores. This chapter summarizes the industry experience behind these choices.
Monolith, microservice, event
For a long time the monolith was the default: business logic, data storage, integrations and the interface in one deployable unit. At small scale it is simple and good. As it grows, so does the risk of change, scaling is possible only as a whole, and the boundaries between business areas blur.
The microservice was the answer to this: smaller, independently deployable units, clearer responsibility, targeted scaling, teams working in parallel. The complexity did not disappear, though; it just moved between the services: communication, state, coordinating processes. The event-driven approach came next, focusing on what happens instead of on calls, and event sourcing grew out of it.
D3I follows this path and does not let you slide back into a hidden monolith. Contexts are microservice boundaries, the main tool of cooperation is the event, long-running processes are workflows. That does not mean every system has to start with maximum complexity, only that the structure can grow from the start.
Every piece of data in the right store
“Everything goes into SQL” is a tempting simplification. In a small system it even works. In a larger system, data is created, changed and read in such different ways that one store can serve each of them only poorly.
← scroll the diagram sideways →
- A relational database where strong consistency and transactions are needed, and the current state always matters: order status, stock, financial records.
- A document store where the data forms a natural unit and moves together: the full description of a product, its attributes, its translations.
- A search engine for fast, flexible search and filtering. It is not a primary store but a server of queries, and it takes load off the operational database.
- A wide-column store for large, continuously appended data, with access by partition. One possible backend for the event store.
- An event store for immutable, chronological events. The full life of an order can be reconstructed from it at any time, and the same store serves the audit.
- An analytical store for reports and statistics, from processed events.
- A cache for frequently read, rarely changing data. A performance tool, not a source.
This variety is manageable only if the domain does not know about it. That is what PolyPersist provides: the repository saves and loads through a uniform interface, and the choice of store remains a decision outside the domain.
A backend in the service of the interface
In the Backend for Frontend (BFF) pattern, user interfaces are not served directly by the interfaces of the domain services, but by a backend layer tailored to the given interface. The web interface, the mobile app and the internal admin interface need different data, in different structures, with different response times. If a single interface tries to serve them all, it becomes an overloaded API that is hard to version.
In D3I a BFF is a versioned interface like any other; only its decorator differs: @public_api(rest, collection="BFF"). From it d3i generates both the TypeScript client for the interface and the .NET API client. Meanwhile the domain model stays uniform.
“This is too complicated”
At first glance the patterns above often look over-engineered. There is a reason for that: they answer the problems of real, large-scale systems, and until a problem shows up, its solution does not seem important either. When the size, load or business complexity of a system reaches a certain level, these decisions suddenly stop being excesses and become prerequisites. By then, however, it is too late to redesign.
D3I promises that an average, mid-level team can make these decisions consistently from day one, without every member having gone through the long, painful and expensive projects that the experience comes from.
Chapter 10
When it is worth it, when it is not, and where the project stands
What this chapter is about
Who D3I is for, who it is not for, what already works and what is still ahead.
It is worth it if…
- the domain is complex: many contexts, aggregates and business rules, and the relations between them matter;
- several developers work on it, and keeping the model in sync is a real problem;
- the system has to appear as a backend service, a gRPC contract and a frontend client at the same time;
- scalability and event-driven operation are requirements from the start, not afterthoughts;
- the business model is expected to change a lot, and the changes have to be handled traceably;
- a junior to mid-level team has to implement senior-level architectural decisions consistently.
It is not worth it if…
- it is a simple, short-lived application. For a weekend CRUD API this is using a sledgehammer to crack a nut;
- the team does not know the concepts of DDD. The language formalizes DDD, but it does not teach it;
- you only want to document the domain, without code generation. A good diagram is enough for that;
- you expect a stable, final API. The project is under active development, and early adopters have to reckon with breaking changes.
Where it stands
D3I is open source, under the Apache 2.0 license. What works today:
- the full language: domain, context, aggregate, entity, value object, composite, enum, view, service, interface, DTO, repository, ACL, the three kinds of fact, event handler and workflow;
- the
validateexpression language with type checking and server- and client-side code generation; - the semantic linter with the DDD rules and the version evolution check;
- the .NET backend, TypeScript client, Protocol Buffers and JSON emitters;
- the Temporal-based workflow skeleton and outbox-based event handling;
- a VS Code extension with a language server, and more than 340 automated tests.
What is still ahead: the Java emitter, the deletion and export mechanism generated from the @gdpr marker, and the public release of the other parts of MicronIQ (SrvKit, Orbit).
Try it
- The source: github.com/gyorgy-gulyas/d3i
- The sample web shop with the full model: github.com/gyorgy-gulyas/samplewebshop
- The related libraries: UniContract and PolyPersist
D3I does not save lines of code. What it does is make sure that the boundaries, the decisions and the consistency of a system do not depend on the team’s memory.
Appendix A
Glossary
English term, Hungarian equivalent, a one-line definition, in alphabetical order of the English term.
| Term | Hungarian | Definition |
|---|---|---|
| aggregate | aggregátum | A unit of entities and value objects that belong together; the boundary of consistency, changed only through its root. |
| aggregate root | aggregátumgyökér | The single entry point of an aggregate (root entity), which enforces the business rules. |
| anti-corruption layer (ACL) | anti-corruption layer | A translation layer between an external system and our own domain, so foreign concepts do not leak in. |
| audit record | audit rekord | Evidence kept for the record (who, what, when); not an event, because nobody reacts to it. |
| Backend for Frontend (BFF) | Backend for Frontend | A backend interface tailored to a specific user interface. |
| bounded context | bounded context | A boundary within which one model and one language apply; in D3I one context is one microservice. |
| compensation | kompenzáció | A step that undoes the effect of a workflow step; on failure they run in reverse order. |
| composite | composite | A reusable, named group of fields inherited by entities and value objects. |
| CQRS | CQRS | Command Query Responsibility Segregation: separating state-changing commands from read-only queries. |
| decorator | dekorátor | A marker starting with @ that gives additional information to linters and emitters. |
| domain | domain | A major business area (e.g. sales, payments); a business grouping, not a technical module. |
| domain event | domain esemény | An internal, unversioned fact of a context, processed within its own deployment unit. |
| DTO | DTO | Data transfer object: a communication data structure, definable only inside an interface; not part of the domain model. |
| emitter | emitter | A d3i module that produces one kind of output from the model (e.g. .NET, TypeScript, proto, JSON). |
| entity | entitás | A business object with an identifier whose state changes over its lifetime. |
| enum | enumeráció | A closed, predefined set of values, such as the possible statuses of an order. |
| event handler | eseménykezelő | A context’s named reaction to an event; the eventhandler keyword in D3I. |
| event sourcing | event sourcing | State is not stored directly but assembled from events, and can be replayed. |
| integration event | integrációs esemény | The public, versioned statement of an internal fact, consumed by other contexts and systems. |
| interface | interfész | The outward-facing, versioned contract of a context; only DTOs, enums and primitives may appear on it. |
| linter | linter | A checker that examines the syntactic, semantic or custom rules of the model before generation. |
| outbox | outbox | The list of outgoing events stored in the same save as the aggregate, from which the platform delivers them. |
| partition key | partíciós kulcs | Tells where an entity belongs in the store; data that belongs together stays together. |
| ref | hivatkozás azonosítóval | A reference between aggregates; it becomes a strongly typed identifier in the generated code. |
| repository | repository | An abstraction that saves and loads aggregates and hides the storage technology. |
| saga | saga | A multi-step process that can be rolled back with compensations; a workflow in D3I. |
| service | service | The internal business operations of a context, using domain types; not reachable from outside. |
| single source of truth | single source of truth | The one authoritative source; in D3I, the .d3 model. |
| ubiquitous language | közös nyelv | The language used by business and development alike, which also appears in the model. |
| validate | validálás | A business rule written next to a field, from which server- and client-side validation code is generated. |
| value object | value object | An immutable value without an identifier, defined by its fields (e.g. address, amount). |
| view | view | A read-optimized projection that can gather data from several sources and cannot be modified. |
| workflow | workflow | A long-running, stateful business process with steps and compensations. |