DDD Anti-Patterns to Avoid

Anemic models, god aggregates, ORM-as-domain shortcuts, and big-bang migrations. The four DDD mistakes I keep watching teams make, with the refactors that actually pulled us out.

OK so the moment I knew our DDD adoption was in trouble was a Thursday afternoon at the London product agency I led engineering at. A backend lead walked over with a laptop, opened a file called OrderService.ts, and scrolled. And scrolled. 1,400 lines. Eleven repositories injected. It “did DDD” because the folder it lived in was named domain/.

Yeah. That.

Most teams that say they’ve adopted DDD have done one of the four things below. I’ve done all of them at different points.

Anemic models with smart services

The first anti-pattern is the one I see most often. The entity is a bag of getters and setters. The “business rules” live in a service that loads the entity, mutates it, and saves it back.

Here’s a before that we shipped, then later regretted:

// domain/orders/order.ts -- the anemic version
export class Order {
  id: string;
  status: "draft" | "paid" | "cancelled";
  items: OrderItem[];
  total: number;

  constructor(props: Partial<Order>) {
    Object.assign(this, props);
  }
}

// application/orders/cancel-order.service.ts -- where the rules actually lived
@Injectable()
export class CancelOrderService {
  async execute(orderId: string, reason: string) {
    const order = await this.orders.findById(orderId);
    if (order.status === "cancelled") throw new Error("already cancelled");
    if (order.status === "paid") {
      await this.refunds.issue(order.id, order.total);
    }
    order.status = "cancelled";
    await this.orders.save(order);
  }
}

That Order is not a domain object. It’s a row. Any caller that forgets the status check will leave the database in a state the model says is illegal. We learned this the hard way when a background job and an admin tool both tried to cancel the same order in the same minute. Double refund. Caught by the finance team, not by us.

The refactor moves the invariants into the entity:

// domain/orders/order.ts -- behavior-rich version
export class Order {
  private constructor(
    public readonly id: OrderId,
    private _status: OrderStatus,
    private readonly _items: ReadonlyArray<OrderItem>,
    private readonly _total: Money,
  ) {}

  static rehydrate(snapshot: OrderSnapshot): Order {
    return new Order(snapshot.id, snapshot.status, snapshot.items, snapshot.total);
  }

  cancel(reason: CancellationReason): DomainEvent[] {
    if (this._status === "cancelled") {
      throw new OrderAlreadyCancelled(this.id);
    }
    const events: DomainEvent[] = [new OrderCancelled(this.id, reason)];
    if (this._status === "paid") {
      events.push(new RefundRequested(this.id, this._total));
    }
    this._status = "cancelled";
    return events;
  }

  toSnapshot(): OrderSnapshot {
    return { id: this.id, status: this._status, items: this._items, total: this._total };
  }
}

Now the only way to cancel an order is to call order.cancel(...). The state machine lives in the model. The service becomes thin and boring, which is what an application service should be.

God aggregates that swallow the domain

The second pattern is the opposite over-correction. Someone reads a DDD book, decides aggregates are powerful, and builds a single aggregate that owns the whole world. Order ends up containing customers, payments, shipments, refunds, reviews, every line item history, and a soft-deleted audit trail. The aggregate is now eight tables deep.

I lived through this on a nightlife discovery and ticketing product we shipped at the same agency. The Event aggregate started as a sensible idea, then someone added attendees, then payments, then table reservations, then bouncer check-ins. Loading one Event aggregate to add a single attendee pulled roughly 40 MB into memory. p99 for the “add to guest list” endpoint sat at 8 seconds. The serializer was the hot path.

The fix is boring: aggregates are consistency boundaries, not containment hierarchies. If two things do not need to commit or fail together inside one transaction, they belong in separate aggregates that reference each other by ID.

// before -- one giant aggregate
class Event {
  attendees: Attendee[];
  payments: Payment[];
  tables: TableReservation[];
  checkIns: CheckIn[];
  reviews: Review[];
  // ...and the methods on top of all this
}

// after -- small aggregates, referenced by ID
class Event {
  constructor(
    public readonly id: EventId,
    private _capacity: number,
    private _status: EventStatus,
  ) {}
  // event-level invariants only
}

class GuestList {
  constructor(
    public readonly id: GuestListId,
    public readonly eventId: EventId,
    private _entries: GuestEntry[],
    private _cap: number,
  ) {}

  addGuest(name: string, addedBy: UserId): DomainEvent[] {
    if (this._entries.length >= this._cap) throw new GuestListFull(this.id);
    this._entries.push({ name, addedBy, at: new Date() });
    return [new GuestAdded(this.eventId, this.id, name)];
  }
}

After that split, the “add to guest list” path loaded one small aggregate instead of the world. p99 fell back under 200 ms. We did not invent a new index, we just stopped lying about what belongs together.

A heuristic that has held up for me: if your aggregate root has more than three or four collections on it, you almost certainly have two aggregates trapped in one.

ORM rows pretending to be the model

The third anti-pattern is the seductive one because it looks like progress. You define your “domain entities” as TypeORM (or Prisma, or ActiveRecord) classes. The decorators stay on. Lazy relations stay on. Callbacks stay on. You tell yourself the persistence concern is “abstracted” because the file lives in domain/.

It is not abstracted. The ORM is now your domain model, and the domain model now leaks lazy-loaded relations, cascade configs, and lifecycle hooks all the way up the stack. Once you stop seeing the model as a thing distinct from the row, every schema change becomes a domain change. There is no separation left to defend.

The structural fix is to treat persistence as a translation step. The domain owns its own shape. A mapper takes the domain object to a row and back:

// infrastructure/orders/order.mapper.ts
import { Order } from "../../domain/orders/order";
import { OrderRow } from "./order.row";

export const OrderMapper = {
  toDomain(row: OrderRow): Order {
    return Order.rehydrate({
      id: row.id,
      status: row.status,
      items: row.items.map(i => ({ sku: i.sku, qty: i.qty, price: new Money(i.price_cents, "USD") })),
      total: new Money(row.total_cents, "USD"),
    });
  },

  toPersistence(order: Order): OrderRow {
    const s = order.toSnapshot();
    return {
      id: s.id,
      status: s.status,
      total_cents: s.total.cents,
      items: s.items.map(i => ({ sku: i.sku, qty: i.qty, price_cents: i.price.cents })),
    };
  },
};
// infrastructure/orders/order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(@InjectRepository(OrderRow) private rows: Repository<OrderRow>) {}

  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.rows.findOne({ where: { id } });
    return row ? OrderMapper.toDomain(row) : null;
  }

  async save(order: Order): Promise<void> {
    await this.rows.save(OrderMapper.toPersistence(order));
  }
}

Boring. That is the point. The domain does not import a single thing from typeorm. Schema migrations become a persistence concern, not a domain rewrite.

Big-bang migrations across the portfolio

Last one. The most expensive mistake I have personally made.

When I made the call to migrate the agency’s portfolio of legacy client projects into a DDD-shaped architecture, my first plan was, in retrospect, beautiful and wrong. Define the ubiquitous language per product, draw the bounded contexts, rewrite each project end-to-end. Six weeks per product. Team eager. Leadership eager. I was eager.

The first product we tried it on, a healthcare professional portal for physicians and pharmacists, took eleven weeks. The product owner watched a feature freeze stretch into its third month and stopped trusting the engineering side of the room. We had built a beautiful aggregate model that nobody could ship to. I had to walk into a room and own that one.

Recovery was architectural but mostly a planning shift. We stopped big-bang. New code in each product was written DDD-shape, old code stayed where it was. The boundary lived at the bounded-context seam, not at the file level. A new context shipped end-to-end DDD. An old one stayed the old shape until a feature needed it changed, and then only that feature carried the cost.

In code, that looks like a context map in the codebase, not in a Miro board:

// shared/context-map.ts
export const contextMap = {
  billing: { style: "ddd-v2", repo: "@org/billing" },
  catalog: { style: "ddd-v2", repo: "@org/catalog" },
  legacyAdmin: { style: "legacy-active-record", repo: "@org/admin" },
  notifications: { style: "anticorruption-layer", repo: "@org/notifications" },
} as const;

// an integration that crosses styles uses a translator at the seam
export class LegacyOrderTranslator {
  toDomain(legacy: LegacyAdminOrderDTO): OrderSnapshot {
    return {
      id: legacy.order_id,
      status: legacy.state === "complete" ? "paid" : "draft",
      items: legacy.line_items.map(l => ({ sku: l.code, qty: l.q, price: new Money(l.cents, "USD") })),
      total: new Money(legacy.total_cents, "USD"),
    };
  }
}

That is the anticorruption layer doing what it is actually for. Not protecting the model from the world. Protecting the model from your last quarter’s model.

A rule that fell out of this and has held up: never migrate a context that does not have a feature on its roadmap. The cheapest DDD migration is the one carried by work the business already wants done.

Takeaways

  • Behavior in the entity, not in a service that mutates a record.
  • Aggregates are transactional consistency boundaries. If two things can commit separately, they are two aggregates.
  • The ORM class is not your domain model. Mappers between rows and the model are boring, and that is a feature.
  • Big-bang DDD migrations end careers. Migrate context-by-context, carried by work the product already wants.
  • A DDD-shaped folder is not a DDD codebase. Look at the entities, not the directory tree.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.