Skip to content

Database ​

CleanSlice uses Prisma as its ORM with PostgreSQL. The key innovation is prisma-import, which lets each slice define its own .prisma schema file. These files are merged into a single schema.prisma at build time, keeping database models close to the domain logic that uses them.

How It Works ​

Slice Prisma Files                    Merged Schema
─────────────────                     ─────────────
slices/user/user.prisma     ─┐
slices/user/team.prisma      ├──▶  prisma/schema.prisma (generated)
slices/file/file.prisma     ─┘              │
                                            ▼
                                    @prisma/client
                                    (auto-generated types)

You edit the per-slice .prisma files. The prisma-import tool merges them. Prisma generates the client from the merged schema.

Installation ​

bash
bun add @prisma/client
bun add -d prisma prisma-import

Install the Prisma Import VSCode extension (ajmnz.prisma-import) for syntax highlighting and IntelliSense in split schema files.

Package Configuration ​

Add the prisma-import config and scripts to your package.json:

json
{
  "scripts": {
    "generate": "bunx prisma-import --force",
    "premigrate": "bunx prisma-import --force",
    "migrate": "dotenv -e .env.dev -- bunx prisma migrate dev && dotenv -e .env.dev -- bunx prisma generate",
    "migrate:prod": "dotenv -e .env.prod -- bunx prisma migrate deploy",
    "studio": "dotenv -e .env.dev -- bunx prisma studio",
    "docker": "docker compose up -d",
    "predev": "bun run docker && bun run migrate"
  },
  "prisma": {
    "import": {
      "schemas": "./src/**/!(schema).prisma",
      "output": "./prisma/schema.prisma"
    }
  }
}

The schemas glob finds all .prisma files in your source tree (excluding schema.prisma itself). The output is where the merged schema lands.

Base Prisma Configuration ​

Create the datasource and generator configuration in the setup slice:

prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["views"]
  binaryTargets   = ["native", "darwin-arm64", "rhel-openssl-3.0.x"]
}

TIP

The binaryTargets array includes rhel-openssl-3.0.x for AWS Lambda/ECS deployments alongside native and darwin-arm64 for local macOS development.

PrismaService ​

The PrismaService wraps the Prisma client and integrates it with the NestJS lifecycle:

typescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

PrismaModule ​

Mark the module as @Global() so every slice can inject PrismaService without importing PrismaModule explicitly:

typescript
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
typescript
export * from './prisma.service';
export * from './prisma.module';

Defining Models ​

Each slice defines its Prisma models in .prisma files at the slice root (not inside subfolders):

slices/
├── setup/
│   └── prisma/
│       └── prisma.prisma          # datasource + generator only
├── user/
│   ├── user.prisma                # User model
│   ├── team.prisma                # Team model
│   └── user/
│       ├── domain/
│       └── data/
└── file/
    ├── file.prisma                # File model
    └── file/
        └── ...

WARNING

Always place .prisma files at the root of each slice directory, not inside data/ or domain/ subfolders. The prisma-import glob pattern expects this layout.

User Model ​

prisma
import { Team } from "./team"

model User {
  id        String   @id
  name      String
  email     String
  roles     String[] @default([])
  verified  Boolean  @default(false)
  banned    Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  teams     Team[]
}

Team Model (with Relations) ​

prisma
import { User } from "./user"
import { File } from "../file/file"

model Team {
  id        String   @id
  name      String
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  files     File[]
}

Cross-Slice Relations ​

prisma
import { Team } from "../user/team"

model File {
  id          String   @id
  teamId      String
  team        Team     @relation(fields: [teamId], references: [id])
  name        String
  contentType String
  path        String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Prisma Import Syntax ​

prisma
// Same slice (sibling files at slice root)
import { User } from "./user"
import { Role } from "./role"

// Different slice
import { File } from "../file/file"
import { Team } from "../user/team"

Import paths reference the .prisma file without the extension, using relative paths from the importing file's location. The model name in braces must match exactly.

Migration Workflow ​

When you change a model:

bash
# 1. Edit the slice .prisma file
# 2. Merge schemas and create migration
bun run migrate
# 3. View data in Prisma Studio (optional)
bun run studio

The premigrate hook runs prisma-import --force automatically before each migration, so you only need one command.

Environment Variables ​

bash
DATABASE_URL=postgres://postgres:[email protected]:5432/cleanslice-api-local-database
bash
# Typically injected via Terraform or AWS Secrets Manager
DATABASE_URL=postgres://admin:${RDS_PASSWORD}@myproject.xxxx.us-east-1.rds.amazonaws.com:5432/myproject

Using PrismaService in Gateways ​

Inject PrismaService in your gateway implementations:

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

@Injectable()
export class UserGateway implements IUserGateway {
  constructor(
    private prisma: PrismaService,
    private map: UserMapper,
  ) {}

  async getUsers(): Promise<IUserData[]> {
    const results = await this.prisma.user.findMany();
    return results.map((result) => this.map.toData(result));
  }

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

TIP

The generated prisma/schema.prisma is auto-generated and will be overwritten. Edit only the per-slice .prisma files.

What's Next? ​

  • Gateways -- See how gateways use PrismaService for data access
  • Mappers -- Learn how mappers transform Prisma types to domain types
  • Slice Structure -- Understand where Prisma files fit in the slice layout

Built with CleanSlice