Domain Model & CQRS: Modernizing your Delphi Architecture
Enterprise Patterns with Dext: Domain Model & CQRS - How to apply Domain Model and CQRS to modernize your Delphi architecture and prepare your system for high scalability.
In corporate software development, we often fall into the trap of using a single class for everything. The same object validates rules, maps to the database, calculates taxes, and serves the UI.
The result is familiar to all of us: rigid systems, “God Classes”, and the constant fear of changing anything in production.
Inspired by Enterprise architecture patterns (commonly seen in the .NET Core and Java ecosystems), Dext brings to Delphi the ability to apply the Separation of Concerns principle in an elegant and performant way.
Today I will demonstrate a hybrid architecture that separates State (Persistence) from Behavior (Business Rules).
1. The Concept: Lightweight Entities vs. Rich Models
Section titled “1. The Concept: Lightweight Entities vs. Rich Models”The secret to robust architecture is accepting that how we store data is different from how we process data.
The Persistence Entity (The State)
Section titled “The Persistence Entity (The State)”This class should be lightweight, a faithful mirror of the table. The database is our “Source of Truth”: if the data is stored there, we assume it is integral.
To keep the code clean, I use Dext’s Fluent Mapping, eliminating the visual pollution of attributes inside the domain class.
// POCO: Pure, lightweight, and focused only on data.type TOrderEntity = class private FId: Integer; FStatus: string; FTotal: Currency; FItems: IList<TOrderItemEntity>; public property Id: Integer read FId write FId; property Status: string read FStatus write FStatus; property Total: Currency read FTotal write FTotal; property Items: IList<TOrderItemEntity> read FItems; end;2. The Rich Domain Model
Section titled “2. The Rich Domain Model”Here lies the “gravity” of business rules. The Model is not a mere data carrier; it is the Guardian of Integrity. Notice how we don’t expose public setters; the only way to change the state is through methods that enforce the rules.
type TOrderModel = class private FEntity: TOrderEntity; // The data target public constructor Create(Entity: TOrderEntity);
// Behaviors (Business Actions) procedure AddItem(Product: TProduct; Quantity: Integer); procedure SubmitOrder;
// Read-Only Access (Total Encapsulation) property Entity: TOrderEntity read FEntity; end;
implementation
procedure TOrderModel.SubmitOrder;begin // 1. State Validation if FEntity.Status <> 'Draft' then raise EDomainError.Create('Only draft orders can be submitted.');
// 2. Invariant Validation ("Hard" Rules) if FEntity.Total < 500.00 then raise EDomainError.Create('Minimum order for billing is $ 500.00.');
// 3. Safe State Transition FEntity.Status := 'AwaitingApproval';end;
procedure TOrderModel.AddItem(Product: TProduct; Quantity: Integer);begin // Fail Fast: Prevents dirty data from entering the system if Quantity <= 0 then raise EDomainError.Create('Invalid quantity.');
var Item := TOrderItemEntity.Create; Item.ProductId := Product.Id; Item.Quantity := Quantity; Item.UnitPrice := Product.SalePrice;
FEntity.Items.Add(Item);
// Updates total immediately. The entity never becomes inconsistent. FEntity.Total := FEntity.Total + (Item.UnitPrice * Quantity);end;3. Elegant Consumption with Fluent Specifications
Section titled “3. Elegant Consumption with Fluent Specifications”The beauty of this architecture is revealed in the Endpoints (Controllers). Since we separated responsibilities, we can use the power of the Specification Pattern (Dext/Ardalis style) integrated into Dext.
See the brutal difference between the two worlds living in harmony:
The Read Endpoint (Query)
Section titled “The Read Endpoint (Query)”Here we use Dext’s fluent syntax. The code is declarative: we say what we want, not how to fetch it.
// GET /orders/pendingApp.MapGet('/orders/pending', function(Context: THttpContext; Repo: IOrderRepository): IResult begin // "Fetch orders where Status is 'Submitted' AND Total > 1000, // including Items, ordered by Date" var Spec := Specification.Where<TOrderEntity>( (OrderEntity.Status = 'Submitted') and (OrderEntity.Total > 1000) ) .Include('Items') .OrderBy(OrderEntity.CreatedAt.Desc);
// The Repository only executes the specification. Clean. var Orders := Repo.ToList(Spec);
Result := Results.Ok(Orders); end);The Write Endpoint (Command)
Section titled “The Write Endpoint (Command)”Here discipline comes into play. We instantiate the Model to ensure no rules are violated before persistence.
// POST /ordersApp.MapPost('/orders', function(Context: THttpContext; Repo: IOrderRepository; Dto: TCreateOrderDto): IResult begin var Entity := TOrderEntity.Create; var Model := TOrderModel.Create(Entity);
try // The Model orchestrates the "heavy" rules for var Item in Dto.Items do Model.AddItem(Item.ProductId, Item.Qty);
Model.SubmitOrder; // Validates state and invariants
// Persists only if the Model approved everything Repo.Add(Model.Entity);
Result := Results.Created(Model.Entity.Id, Model.Entity); except on E: EDomainError do Result := Results.BadRequest(E.Message); end; end);4. Checkmate: Unit Testability
Section titled “4. Checkmate: Unit Testability”By adopting this pattern, we gain a benefit worth gold in large projects: the ability to test business rules in isolation.
In the old model (“God Class” coupled to the database component), to test if the “Minimum Order of $ 500.00” works, you would need to spin up a database, insert records, and run the system. Slow and fragile.
In this hybrid architecture, TOrderModel is database-agnostic. You can write a Unit Test (DUnit/DUnitX) that instantiates the entity in memory, passes it to the Model, and verifies the behavior in milliseconds.
procedure TTestOrderModel.TestMinimumValidation;var Entity: TOrderEntity; Model: TOrderModel;begin // Arrange: In-memory Entity, no database! Entity := TOrderEntity.Create; Entity.Total := 100.00; Model := TOrderModel.Create(Entity);
// Act & Assert Assert.WillRaise(procedure begin Model.SubmitOrder end, EDomainError, 'Should block orders below 500 dollars' );end;This ensures software quality long before it reaches production.
Conclusion
Section titled “Conclusion”Adopting Enterprise Patterns with Dext transforms your Delphi project engineering. By separating State from Behavior, we achieve the perfect balance:
- Read: Expressive and performant with Fluent Specifications.
- Write: Safe and armored with Domain Models.
- Quality: Real testability outside the database.
Dext provides the foundation (ORM, Fluent Mapping, Specifications) so you can stop fighting the code and start designing robust architectures.
This is just the first step. Once your domain is rich and isolated, new possibilities open up: Event Sourcing, message queues, and high-performance APIs. But that’s a conversation for our next article.
Check out the Dext repository on GitHub: https://github.com/cesarliws/dext