The Illusion of Understanding
When developers first encounter a new business domain, there is a strong temptation to start modelling the data. It feels productive because the business is full of things that can easily be turned into entities: customers, orders, invoices, products, appointments, suppliers, and warehouses. Before long, diagrams begin to emerge, tables are sketched out, relationships are drawn, and a structure starts to take shape.
The problem is that this activity creates an illusion of understanding.
A well-structured data model can make it feel as though we have captured the essence of a business when in reality we have only described the information it happens to store. We know that a customer has orders, that an order has order lines, and that an invoice belongs to a customer, but none of that tells us how the business actually works. It tells us what information exists, not why it exists, how it changes over time, or what rules govern it.
This distinction is easy to miss because data is visible. Most business processes leave traces in the form of records, and those records are often the first thing a developer encounters. A database is tangible and easy to inspect, while a decision, a policy, or a business rule is much harder to see. As a result, many systems end up being designed around the artifacts left behind by the business rather than around the business itself.
The trap usually does not reveal itself immediately. In the beginning, everything feels straightforward because the business itself is still simple. A work order can be created, updated, completed, and eventually invoiced. A data model seems perfectly capable of supporting those requirements.
Imagine we are building a workshop management system. One of the central concepts is a work order.
public class WorkOrder
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid VehicleId { get; set; }
public WorkOrderStatus Status { get; set; }
public bool WarrantyJob { get; set; }
public bool WarrantyApproved { get; set; }
public bool AdditionalWorkRequired { get; set; }
public bool AdditionalWorkApproved { get; set; }
}
Nothing about this model looks suspicious. In fact, most developers would probably consider it a good start. It captures the information we currently know about a work order and gives us a structure that can easily be persisted to a database.
Then a stakeholder asks a simple question:
Can this work order be invoiced?
At first, the answer is almost trivial.
return workOrder.Status == WorkOrderStatus.Completed;
The model appears to work perfectly.
Then the business evolves.
Warranty work requires approval before invoicing. Additional work discovered during a repair must be approved by the customer. Parts ordered for the repair must have been received. All technician tasks must be completed. Mandatory inspections must be signed off. Customers with overdue balances may require special approval before the invoice can be issued.
The interesting thing is that the original model is no longer sufficient to answer the question. Whether a work order can be invoiced now depends on information scattered throughout the business. Technician tasks have their own lifecycle, part orders are managed separately, inspections follow their own process, and customer credit information belongs to a completely different area of the system. The question has not changed, but the amount of knowledge required to answer it has grown considerably.
Sooner or later, somebody needs to answer the original question again:
Can this work order be invoiced?
Answering that question now requires information from several different parts of the system. The work order itself is no longer enough because the decision depends on technician tasks, part orders, inspections, warranty claims, and customer credit information.
A common response is to move the decision into an application service because that is the place where all the information can be assembled.
public sealed class InvoiceWorkOrderService
{
private readonly IWorkOrderRepository _workOrders;
private readonly ITechnicianTaskRepository _tasks;
private readonly IPartOrderRepository _partOrders;
private readonly IWarrantyClaimRepository _warrantyClaims;
private readonly IInspectionRepository _inspections;
private readonly IInvoiceRepository _invoices;
public async Task Invoice(Guid workOrderId)
{
var workOrder =
await _workOrders.Get(workOrderId);
var tasks =
await _tasks.GetByWorkOrder(workOrderId);
var openPartOrders =
await _partOrders.GetOpenOrders(workOrderId);
var warrantyClaim =
await _warrantyClaims.GetByWorkOrder(workOrderId);
var inspections =
await _inspections.GetByWorkOrder(workOrderId);
if (workOrder.Status != WorkOrderStatus.Completed)
throw new InvalidOperationException();
if (tasks.Any(t => t.Status != Completed))
throw new InvalidOperationException();
if (openPartOrders.Any())
throw new InvalidOperationException();
if (warrantyClaim is not null &&
!warrantyClaim.Approved)
throw new InvalidOperationException();
if (workOrder.AdditionalWorkRequired &&
!workOrder.AdditionalWorkApproved)
throw new InvalidOperationException();
if (inspections.Any(i => !i.SignedOff))
throw new InvalidOperationException();
workOrder.Status = WorkOrderStatus.Invoiced;
await _invoices.CreateFor(workOrder);
await _workOrders.Save(workOrder);
}
}
This code is not terrible. It probably works. Many of us have written code that looks very similar. The service loads what it needs, checks a series of conditions, changes the state, and saves the result.
The problem is more subtle than bad code.
The application service has become the owner of the business decision.
If another part of the system needs to know whether a work order can be invoiced, perhaps to enable a button in the user interface, validate an API request, or run a nightly batch process, the rule must either be duplicated or the application service must somehow be reused for a question it was never designed to answer.
Over time, different versions of the same rule begin to appear in different places. One version lives in a command handler. Another version lives in the UI. A third version appears in a report. They all start the same and then slowly drift apart.
The WorkOrder still exists, but it no longer explains the business. It stores information about the process without describing the process itself.
This is the moment when the trap closes.
The model looked successful because it accurately represented the data, but the moment we started asking business questions, we discovered that the knowledge required to answer them was scattered throughout the system. The model could tell us what a work order looked like, but it could not tell us what a work order meant.
A domain model starts from a different place. Rather than asking what information a work order contains, it asks what role a work order plays within the business and what decisions it is responsible for protecting.
The repositories still exist. The application service still exists. The information still needs to be loaded. None of that changes.
What changes is where the decision is made.
public sealed class InvoiceWorkOrderHandler
{
private readonly IWorkOrderRepository _workOrders;
private readonly ITechnicianTaskRepository _tasks;
private readonly IPartOrderRepository _partOrders;
private readonly IWarrantyClaimRepository _warrantyClaims;
private readonly IInspectionRepository _inspections;
public async Task Handle(Guid workOrderId)
{
var workOrder =
await _workOrders.Get(workOrderId);
var requirements = new InvoicingRequirements(
TechnicianTasks:
await _tasks.GetByWorkOrder(workOrderId),
OpenPartOrders:
await _partOrders.GetOpenOrders(workOrderId),
WarrantyClaim:
await _warrantyClaims.GetByWorkOrder(workOrderId),
Inspections:
await _inspections.GetByWorkOrder(workOrderId));
workOrder.Invoice(requirements);
await _workOrders.Save(workOrder);
}
}
The responsibility of the application service is now orchestration. It gathers information, constructs the context required for the decision, and hands control to the domain model.
The business rule itself lives with the concept that owns it.
public sealed class WorkOrder
{
public void Invoice(
InvoicingRequirements requirements)
{
if (Status != WorkOrderStatus.Completed)
throw new InvalidOperationException();
if (!requirements.AllTechnicianTasksCompleted)
throw new InvalidOperationException();
if (requirements.HasOpenPartOrders)
throw new InvalidOperationException();
if (requirements.WarrantyRequiresApproval)
throw new InvalidOperationException();
if (AdditionalWorkRequired &&
!AdditionalWorkApproved)
throw new InvalidOperationException();
if (!requirements.AllInspectionsSignedOff)
throw new InvalidOperationException();
Status = WorkOrderStatus.Invoiced;
}
}
The business is still complex. The model has not magically removed that complexity. What has changed is where the complexity lives.
In the data model version, the complexity is scattered. A developer trying to understand invoicing must reconstruct the process by following code across repositories, services, queries, validators, and workflows. In the domain model version, the complexity is concentrated around a business concept. The model becomes a place where a developer can learn how the business operates rather than merely where its data is stored.
The benefits become even more apparent when testing.
[Fact]
public void cannot_invoice_when_parts_are_still_on_order()
{
var workOrder = WorkOrder.Completed();
var requirements = new InvoicingRequirements(
TechnicianTasks:
[TechnicianTask.Completed()],
OpenPartOrders:
[PartOrder.Open()],
WarrantyClaim:
null,
Inspections:
[Inspection.SignedOff()]);
Assert.Throws<InvalidOperationException>(
() => workOrder.Invoice(requirements));
}
There are no repositories. No database. No application service. No mocks for five different dependencies just to verify a single business rule. The test describes a business situation directly, which is usually a good sign that the model is carrying the right responsibility.
A useful test is to imagine a new developer joining the team and asking a simple question:
What does it mean for a work order to become invoiced?
If the answer requires a tour through half the codebase, there is a good chance the model has stopped modelling the business and started modelling the database. If the answer can be found by examining the business concepts themselves, the model is probably pulling its weight.
The goal is not to avoid data. Every system needs data and every domain model eventually needs to be persisted somewhere. The danger lies in allowing the persistence model to become the business model. Once that happens, the software begins to mirror the structure of the database rather than the structure of the business, and developers slowly find themselves maintaining records instead of modelling behaviour.