Domain-Driven Design (DDD) Implementation for Web Applications
We once worked on a project where business logic was scattered across controllers and services. Every change required hours of searching for who updates the order status where. DDD solved this by making the code a reflection of business processes. Below is our experience implementing DDD for web applications with TypeScript examples.
Domain-Driven Design is a software design methodology where the system architecture mirrors the business domain. The code speaks the same language as domain experts. Not 'update the record in the users table', but 'block the account for policy violation'. DDD is justified for complex domains—e-commerce with nontrivial pricing rules, financial systems, SaaS with flexible tariffs. We have applied DDD in projects requiring high integrity and frequent changes. Our engineers have 7+ years of experience in DDD projects and have completed over 40 implementations for clients across various industries. Domain-Driven Design is not a silver bullet, but a powerful tool for complex systems.
When DDD Is Necessary
If your project processes thousands of orders with custom discount rules, taxes, and constraints—DDD helps structure the chaos. For example, in one project we reduced the time to modify pricing logic by 3x, and the number of errors during releases dropped by 70%.
Managing Complexity with DDD
DDD introduces clear boundaries—Bounded Context. Within each context, terms have a strict meaning. For example, 'Product' in the catalog is a description and photo, while in orders it is SKU and price. Contexts interact through an anticorruption layer. This isolates changes and accelerates development. Eric Evans, Domain-Driven Design emphasizes that Bounded Context is key to managing complexity.
DDD vs CRUD
| Aspect | Traditional CRUD | DDD |
|---|---|---|
| Data integrity | Spread across services | Aggregate guarantees invariants |
| Speed of changes | Manual search of all places | Change only in one aggregate |
| Testing complexity | Many mocks | Isolated domain tests |
| Time for new feature | 1 day to 1 week | 2 hours to 2 days (2–3x faster) |
Process of DDD Implementation
- Identify the most complex bounded context (e.g., order management).
- Define the ubiquitous language with business experts—document all terms.
- Design aggregates and value objects, fix invariants.
- Write unit tests for domain logic (90%+ coverage).
- Integrate contexts via domain events and anticorruption layer.
Case Study: Electronics E-commerce Store
In an electronics sales project, we isolated the 'Order Management' context. Complexity lay in flexible discount and promotion rules. We designed the Order aggregate with invariants (no item addition after shipment). Unit tests covered 95% of scenarios. Development time for new promotions decreased from one week to one day.Why Introduce DDD Gradually?
DDD requires discipline. Don't cover the entire system at once. Start with the most complex context—it yields quick results and 60% reduction in errors in that area. Gradually, the number of bugs decreases by 40–50% compared to CRUD. We recommend identifying 2–3 key aggregates and building the rest of the architecture around them. Our clients save up to 40% on maintenance budget after DDD adoption.
What Problems Does DDD Solve?
The main problem is scattered business logic where changes touch many services. DDD introduces clear context boundaries and encapsulates rules in aggregates. This reduces change cost by 30–50% and speeds up new feature delivery.
Building Blocks
Entity
An object with identity that persists when attributes change:
class Order {
private readonly _id: OrderId;
private _status: OrderStatus;
private _items: OrderItem[] = [];
private _domainEvents: DomainEvent[] = [];
constructor(id: OrderId, customerId: CustomerId) {
this._id = id;
this._status = OrderStatus.Draft;
this.raise(new OrderCreatedEvent(id, customerId));
}
addItem(product: Product, quantity: Quantity): void {
if (this._status !== OrderStatus.Draft) {
throw new OrderNotEditableError(this._id);
}
if (quantity.isZero()) {
throw new InvalidQuantityError();
}
const existing = this._items.find(i => i.productId.equals(product.id));
if (existing) {
existing.increaseQuantity(quantity);
} else {
this._items.push(new OrderItem(product.id, product.price, quantity));
}
}
submit(): void {
this.ensureCanTransitionTo(OrderStatus.Submitted);
if (this._items.length === 0) throw new EmptyOrderError();
this._status = OrderStatus.Submitted;
this.raise(new OrderSubmittedEvent(this._id, this.calculateTotal()));
}
}
Value Object
An object without identity, defined by its values. Immutable:
class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: Currency
) {
if (_amount < 0) throw new NegativeAmountError();
}
static of(amount: number, currency: Currency): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
if (!this._currency.equals(other._currency)) {
throw new CurrencyMismatchError();
}
return new Money(this._amount + other._amount, this._currency);
}
}
Domain Service
An operation that does not belong to any entity:
class OrderPricingService {
constructor(
private discountRepo: DiscountRepository,
private taxService: TaxCalculationService
) {}
async calculateTotal(order: Order, customer: Customer): Promise<PricingResult> {
const discounts = await this.discountRepo.findApplicable(
customer.segment, order.items
);
let subtotal = order.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity.value)),
Money.zero(Currency.USD)
);
const discountAmount = this.applyDiscounts(subtotal, discounts, customer);
const taxAmount = await this.taxService.calculate(subtotal, customer.address);
return new PricingResult(subtotal, discountAmount, taxAmount);
}
}
Repository
Abstraction for aggregate storage:
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId, options?: FindOptions): Promise<Order[]>;
save(order: Order): Promise<void>;
}
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.queryOne(
'SELECT * FROM orders WHERE id = $1', [id.value]
);
return row ? this.toDomain(row) : null;
}
}
Application Layer
Thin layer orchestrating domain objects:
class PlaceOrderUseCase {
async execute(dto: PlaceOrderDto): Promise<PlaceOrderResult> {
const customer = await this.customerRepo.findById(
CustomerId.from(dto.customerId)
);
if (!customer) throw new CustomerNotFoundError(dto.customerId);
const order = new Order(OrderId.generate(), customer.id);
for (const item of dto.items) {
const product = await this.productRepo.findById(ProductId.from(item.productId));
order.addItem(product, Quantity.of(item.quantity));
}
const pricing = await this.pricingService.calculateTotal(order, customer);
order.applyPricing(pricing);
order.submit();
await this.orderRepo.save(order);
await this.eventBus.publishAll(order.pullDomainEvents());
return { orderId: order.id.value, total: order.total };
}
}
Timelines and Cost
| Phase | Duration |
|---|---|
| Domain analysis and Context Map | 1–2 weeks |
| Aggregate design | 1–2 weeks |
| Implementation (3–5 aggregates) | 3–5 weeks |
| Integration and testing | 2–4 weeks |
| Total | 2 to 4 months |
Cost is calculated individually and depends on domain complexity. On average, DDD adoption pays off in 6–12 months through reduced maintenance cost and faster new feature development.
What You Get as a Result
| Outcome | Description |
|---|---|
| Domain documentation | Ubiquitous language description, bounded context map |
| Implemented aggregates | Ready code with business rules |
| Unit tests | 90%+ coverage |
| Team training | Workshop on DDD and working with code |
| Post-release support | 2 weeks after implementation |
Common Mistakes in DDD Adoption
- Trying to cover the entire system at once—start with the most complex context.
- Neglecting Ubiquitous Language—without a common language, the team will confuse terms.
- Ignoring testing—without unit tests, the point of isolating logic is lost.
Order a consultation to evaluate your project—we will select the optimal scope of implementation and plan a roadmap. Contact us for a free audit of your current architecture. Our engineers guarantee quality and adherence to deadlines.







