Skip to content

Mappers

Mappers transform data between layers. They convert Prisma database models into domain types and prepare domain data for database writes. Mappers live in the data/ folder and contain pure, synchronous transformations with no business logic.

Why Mappers Exist

Prisma generates its own types based on your database schema. Your domain layer defines its own types based on business needs. These two sets of types are similar but rarely identical -- Prisma types include database-specific details, your domain types use typed enums, and some fields need reshaping.

Mappers keep this translation logic in one place instead of scattering it across gateways and services.

File Location

slices/user/
├── domain/
│   └── user.types.ts           # IUserData, ICreateUserData, etc.
└── data/
    ├── user.gateway.ts         # Uses the mapper
    └── user.mapper.ts          # Mapper lives here

Naming: {entity}.mapper.ts (singular file), {Entity}Mapper (singular class).

Standard Methods

Every mapper has up to three methods:

MethodInputOutputPurpose
toData()Prisma modelDomain typeDatabase response to domain
toCreate()Create inputPrisma create inputPrepare data for insert
toUpdate()Update inputPrisma update inputPrepare data for update

Complete Example

typescript
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import DB, { Prisma } from '@prisma/client';
import { v4 as uuid } from 'uuid';
import { IUserData, ICreateUserData, IUpdateUserData, RoleTypes } from '../domain';

// Type aliases for Prisma types
export type IUserResponse = DB.User;
export type IUserCreateRequest = Prisma.XOR<
  Prisma.UserCreateInput,
  Prisma.UserUncheckedCreateInput
>;
export type IUserUpdateRequest = Prisma.XOR<
  Prisma.UserUpdateInput,
  Prisma.UserUncheckedUpdateInput
>;

@Injectable()
export class UserMapper {
  constructor(private configService: ConfigService) {}

  toData(data: IUserResponse): IUserData {
    return {
      id: data.id,
      name: data.name,
      email: data.email,
      roles: data.roles as RoleTypes[],
      verified: data.verified,
      banned: data.banned,
      createdAt: data.createdAt,
      updatedAt: data.updatedAt,
    };
  }

  toCreate(data: ICreateUserData): IUserCreateRequest {
    const id = `user-${uuid()}`;
    return {
      id,
      name: data.name,
      email: data.email,
      roles: data.roles,
      verified: this.configService.get('IS_USER_VERIFIED') === 'true',
    };
  }

  toUpdate(data: IUpdateUserData): IUserUpdateRequest {
    return {
      name: data.name,
      roles: data.roles,
      verified: data.verified,
      banned: data.banned,
    };
  }
}

Rules for Mappers

Mappers Are Synchronous

Mapper methods are pure transformations. They receive data in, return data out, and make no async calls.

typescript
// Correct -- synchronous transformation
toData(user: PrismaUser): IUserData {
  return { id: user.id, name: user.name };
}

// Wrong -- async and fetching data
async toData(userId: string): Promise<IUserData> {
  const user = await this.prisma.user.findUnique({ where: { id: userId } });
  return { id: user.id, name: user.name };
}

No Database Calls

If a transformation needs related data, pass it as a parameter from the gateway:

typescript
// Wrong -- mapper fetches related data
toData(user: PrismaUser): IUserData {
  const roles = await this.prisma.role.findMany({ where: { userId: user.id } });
  return { ...user, roles };
}

// Correct -- related data passed in
toData(user: PrismaUser, roles: PrismaRole[]): IUserData {
  return { ...user, roles: roles.map(r => r.name) };
}

No Business Logic

Validation, error throwing, and business rules belong in the Service. Mappers only reshape data.

typescript
// Wrong -- business logic in mapper
toData(user: PrismaUser): IUserData {
  if (user.status === 'banned') {
    throw new UserBannedError(user.id);
  }
  return { id: user.id, name: user.name };
}

// Correct -- pure transformation
toData(user: PrismaUser): IUserData {
  return {
    id: user.id,
    name: user.name,
    status: user.status as UserStatusTypes,
  };
}

Handling Complex Transformations

Relations

When your gateway uses Prisma's include, define a type for the enriched result and create a dedicated mapper method:

typescript
type UserWithPosts = Prisma.UserGetPayload<{
  include: { posts: true };
}>;

toDataWithPosts(user: UserWithPosts): IUserWithPosts {
  return {
    ...this.toData(user),
    posts: user.posts.map((post) => ({
      id: post.id,
      title: post.title,
      createdAt: post.createdAt,
    })),
  };
}

Enum Casting

Prisma stores enums as strings. Cast them to your domain enum types in the mapper:

typescript
toData(user: PrismaUser): IUserData {
  return {
    id: user.id,
    status: user.status as UserStatusTypes,
    role: user.role as UserRoleTypes,
    permissions: user.permissions as PermissionTypes[],
  };
}

JSON Fields

typescript
toData(user: PrismaUser): IUserData {
  return {
    id: user.id,
    settings: user.settings as IUserSettings,
    metadata: (user.metadata as IUserMetadata) ?? {},
  };
}

toCreate(data: ICreateUserData): Prisma.UserCreateInput {
  return {
    name: data.name,
    settings: data.settings as Prisma.JsonObject,
  };
}

ID Generation

Generate prefixed IDs in the toCreate method:

typescript
import { v4 as uuid } from 'uuid';

toCreate(data: ICreateUserData): Prisma.UserCreateInput {
  return {
    id: `user-${uuid()}`,
    ...data,
  };
}

Common prefixes: user-, team-, file-, api-key-.

Cross-Mapper Composition

When an entity includes related data from another slice, inject that slice's mapper:

typescript
import { Injectable } from '@nestjs/common';
import DB, { Prisma } from '@prisma/client';
import { IApiKeyData, ICreateApiKeyData } from '../domain';
import { TeamMapper } from '#/user/team/data/team.mapper';
import { v4 as uuid } from 'uuid';

export type IApiKeyResponse = DB.ApiKey & {
  team: DB.Team;
};

@Injectable()
export class ApiKeyMapper {
  constructor(private readonly teamMapper: TeamMapper) {}

  toData(data: IApiKeyResponse): IApiKeyData {
    return {
      id: data.id,
      teamId: data.teamId,
      team: this.teamMapper.toData(data.team),
      name: data.name,
      secret: data.secret,
      createdAt: data.createdAt,
      updatedAt: data.updatedAt,
    };
  }

  toCreate(data: ICreateApiKeyData): Prisma.ApiKeyUncheckedCreateInput {
    return {
      id: `api-key-${uuid()}`,
      teamId: data.teamId,
      name: data.name,
      secret: `secret-${uuid()}`,
    };
  }
}

The gateway must use include to load the relation:

typescript
async getApiKey(id: string): Promise<IApiKeyData | null> {
  const result = await this.prisma.apiKey.findUnique({
    where: { id },
    include: { team: true },
  });
  return result ? this.mapper.toData(result) : null;
}

Using the Mapper in a Gateway

typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '#/setup/prisma';
import { IUserGateway } from '../domain/user.gateway';
import { IUserData, ICreateUserData, IUpdateUserData } from '../domain/user.types';
import { UserMapper } from './user.mapper';

@Injectable()
export class UserGateway extends IUserGateway {
  constructor(
    private prisma: PrismaService,
    private mapper: UserMapper,
  ) {
    super();
  }

  async getUser(id: string): Promise<IUserData | null> {
    const result = await this.prisma.user.findUnique({ where: { id } });
    return result ? this.mapper.toData(result) : null;
  }

  async createUser(data: ICreateUserData): Promise<IUserData> {
    const result = await this.prisma.user.create({
      data: this.mapper.toCreate(data),
    });
    return this.mapper.toData(result);
  }

  async updateUser(id: string, data: IUpdateUserData): Promise<IUserData> {
    const result = await this.prisma.user.update({
      where: { id },
      data: this.mapper.toUpdate(data),
    });
    return this.mapper.toData(result);
  }
}

Module Registration

Register the mapper as a provider in the module:

typescript
import { Module } from '@nestjs/common';
import { UserGateway } from './data/user.gateway';
import { UserMapper } from './data/user.mapper';

@Module({
  providers: [
    UserMapper,
    { provide: IUserGateway, useClass: UserGateway },
  ],
})
export class UserModule {}

What's Next?

  • Gateways -- Where mappers are used for data access
  • Types -- Domain types that mappers convert to and from
  • DTOs -- API-facing types that parallel domain types

Built with CleanSlice