Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 042 / 100

DTOs and mappers: a Design Patterns guide for developers

Keep your API contract separate from your domain.

Expona Admin8 min read

DTOs and mappers: a Design Patterns guide for developers

Your domain model knows everything about a user: internal flags, audit timestamps, encrypted fields, denormalized caches. Your API consumers need only a name, email, and ID. The gap between those two worlds is where DTOs and mappers live, and getting them right transforms how stable and maintainable your API becomes.

In this guide, we'll walk through the design patterns that let you keep your API contract separate from your domain, protect internal implementation details, and evolve both without stepping on each other's toes.

Why DTOs — Hide internal fields and stabilize API contracts

A DTO (Data Transfer Object) is a simple container that holds only the data your API consumer needs. It's not your database model. It's not your domain entity. It's a deliberate, minimal projection.

Consider a Lead entity in a CRM system:

typescript
interface Lead {
  id: string;
  name: string;
  email: string;
  status: "new" | "contacted" | "converted" | "lost";
  owner: User;
  createdAt: Date;
  updatedAt: Date;
  internalNotes: string;
  companyId: string;
  scoringAlgorithmVersion: number;
  isSpam: boolean;
  lastReviewedBy: string;
  confidenceScore: number;
}

Now, your mobile client hits your /leads/:id endpoint. Does it need lastReviewedBy, scoringAlgorithmVersion, or isSpam? Probably not. It needs a clean, focused view. That's your DTO:

typescript
interface LeadDto {
  id: string;
  name: string;
  email: string;
  status: "new" | "contacted" | "converted" | "lost";
  ownerName: string | null;
}

Benefits compound quickly:

  • Security. Internal flags, audit fields, and confidence scores stay off the wire.
  • Contract stability. Your domain model can grow and change without breaking clients. You add scoringAlgorithmVersion to Lead? Clients never see it.
  • Performance. Small DTOs serialize faster and reduce payload size. In ride-hailing or real-time trading, that matters.
  • Clarity. A DTO is a promise to the client: "This is exactly what you'll receive." No surprises.

The cost is minimal: a small interface definition and a mapping function. Well worth it in any production system.

Explicit mapping — A toDto() function is easy to read and test

The simplest, most maintainable approach is an explicit mapper function. No magic, no reflection, no codegen. Just a function:

typescript
export const toLeadDto = (l: Lead): LeadDto => ({
  id: l.id,
  name: l.name,
  status: l.status,
  owner: l.owner?.name ?? null,
});

This single function is your entire API contract. It's immediately clear what fields are exposed. A junior engineer reading this knows exactly what a client will receive. No hidden behavior, no metadata scanning, no implicit conventions.

In your controller or resolver, you use it like this:

typescript
async getLeadById(id: string): Promise<LeadDto> {
  const lead = await this.leadRepository.findById(id);
  if (!lead) throw new NotFoundError();
  return toLeadDto(lead);
}

Testing is trivial:

typescript
describe("toLeadDto", () => {
  it("maps a lead entity to DTO", () => {
    const lead: Lead = {
      id: "123",
      name: "Alice",
      status: "contacted",
      owner: { id: "456", name: "Bob" },
      // ... other fields
    };

    const dto = toLeadDto(lead);

    expect(dto).toEqual({
      id: "123",
      name: "Alice",
      status: "contacted",
      owner: "Bob",
    });
  });

  it("handles null owner gracefully", () => {
    const lead: Lead = {
      id: "123",
      name: "Alice",
      status: "new",
      owner: null,
      // ... other fields
    };

    const dto = toLeadDto(lead);

    expect(dto.owner).toBeNull();
  });
});

One function, isolated, no dependencies. That's the pattern. Scale this across your codebase: one mapper per entity type, one test suite per mapper. Your team will thank you when they need to trace where a field came from or why a client isn't receiving something they expect.

Mapping libraries — Use them for big, simple mappings — not business logic

As your system grows, you might have dozens of entities and multiple DTO versions. Libraries like automapper-js or class-transformer promise to reduce boilerplate. They can help — if you use them correctly.

Automapper works well for straightforward, 1:1 mappings with conventions:

typescript
import { Mapper, classes } from "@automapper/core";

createMap(mapper, Lead, LeadDto);

// Automatically maps id → id, name → name, status → status
const dto = mapper.map(lead, Lead, LeadDto);

This scales. You define the mapping once, reuse it everywhere. For large systems with many entities, that's real time saved.

But here's the catch: don't hide business logic inside a mapper. If your mapping rule is "only expose owner name if the lead is converted or the user has admin access," that's not a mapping concern. It's a business rule. Keep it in your service layer:

typescript
// Bad: business logic inside mapper metadata
createMap(mapper, Lead, LeadDto)
  .forMember(
    (dest) => dest.owner,
    (opts) => opts.mapFrom(
      (src) => src.owner && isLeadConverted(src) ? src.owner.name : null
    )
  );

// Good: business logic in service, mapper is dumb
export const toLeadDto = (l: Lead, userIsAdmin: boolean): LeadDto => ({
  id: l.id,
  name: l.name,
  status: l.status,
  owner:
    userIsAdmin || l.status === "converted"
      ? l.owner?.name ?? null
      : null,
});

The explicit function is easier to test, easier to debug, and easier to understand in six months. Mapping libraries are tools for repetitive, predictable transformations. Don't ask them to think.

Version DTOs — Breaking changes create a new DTO version

Your API ships. Clients adopt it. Six months later, your product team wants to add a new field, or change the shape of an object, or remove something deprecated. What do you do?

You don't modify the existing DTO. You create a new one:

typescript
// Original DTO, ships in v1
interface LeadDtoV1 {
  id: string;
  name: string;
  status: "new" | "contacted" | "converted" | "lost";
  owner: string | null;
}

// v2 adds confidence score, renames owner to ownerName
interface LeadDtoV2 {
  id: string;
  name: string;
  status: "new" | "contacted" | "converted" | "lost";
  ownerName: string | null;
  confidenceScore: number;
}

Your endpoints now return the newer version. Old clients either continue using v1 (via a query param or header), or they upgrade. Your team controls the timeline, and existing integrations don't break unexpectedly.

In practice, you might expose both in your routing layer:

typescript
router.get("/leads/:id", async (req, res) => {
  const lead = await leadRepository.findById(req.params.id);
  const version = req.query.version ?? "v2";

  let dto;
  if (version === "v1") {
    dto = toLeadDtoV1(lead);
  } else {
    dto = toLeadDtoV2(lead);
  }

  res.json(dto);
});

Or, for cleaner separation, bundle versioned DTOs into namespace objects:

typescript
export const LeadDto = {
  v1: (l: Lead): LeadDtoV1 => ({ /* ... */ }),
  v2: (l: Lead): LeadDtoV2 => ({ /* ... */ }),
};

As v1 usage drops, you can deprecate and remove it. But you own the timeline. No surprises. No "why did the client break?" messages at 2 AM.

Common mistakes

Mistake 1: Reusing domain entities as DTOs. "Why not just return the Lead entity directly?" Because your domain model is for internal use. It will change. Your API will break. Decouple them from day one.

Mistake 2: Over-fetching in your mapper. If your toLeadDto function makes database queries or calls external services, you've created a hidden performance problem. Mappers transform in-memory objects only. Fetch data in your service layer, then map it.

typescript
// Bad
export const toLeadDto = async (leadId: string) => {
  const lead = await db.leads.findById(leadId); // Hidden query
  const owner = await db.users.findById(lead.ownerId); // Hidden query
  return { id: lead.id, name: lead.name, ownerName: owner.name };
};

// Good
export const toLeadDto = (l: Lead): LeadDto => ({
  id: l.id,
  name: l.name,
  ownerName: l.owner?.name ?? null, // Data already loaded
});

Mistake 3: Mapping libraries with implicit conventions. "AutoMapper will handle it." If new team members can't read your mapping and understand what's happening, you've gone too far. Keep it visible and explicit, especially for complex transformations.

Mistake 4: Ignoring null or optional fields. A DTO that sometimes returns owner and sometimes doesn't confuses clients. Use explicit nulls or separate optional fields. Be predictable.

Key takeaways

  • DTOs are your API contract. They isolate your domain model from your clients, enabling independent evolution.
  • Write explicit mapper functions. They're testable, readable, and require no special framework knowledge.
  • Use mapping libraries for repetitive, simple transformations. Don't hide business logic inside them.
  • Version your DTOs when you make breaking changes. Keep old versions alive until adoption of the new one is safe.
  • Never fetch data inside a mapper. Mappers transform in-memory objects only.
  • Make your API contract clear and predictable. Future you, and your team, will be grateful.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles