Skip to content
vv1.14.0
Main

Complete Example

This example demonstrates the complete data flow when creating a new domain, showing how all the architectural layers work together. We will analyze each file and explain how they interact.

Overview

We'll create a complete domain with:

  • Database table definition
  • Validation schemas
  • REST API endpoints (CRUD)
  • Business logic with caching
  • Type-safe operations

Step 1: Generate the Domain

bash
bun gen:domain product
bash
npm run gen:domain product
bash
yarn gen:domain product
bash
pnpm gen:domain product

This creates the complete structure. Let's examine each generated file.

Step 2: Understanding the Generated Files

2.1 Entity Definition (src/domain/product/entity.ts)

The entity defines the database table structure using Drizzle ORM:

typescript
import { identifier, pgIndex } from "@infrastructure/repositories/references";
import { pgTable, varchar, number } from "drizzle-orm/pg-core";

const columns = {
  name: varchar("name", { length: 50 }).notNull(),
  price: number("price", { length: 100 }),
  description: varchar("description", { length: 400 }).unique().notNull(),
  
};

const product = pgTable(
  "product", 
  { ...columns, ...identifier }, 
  (table) => pgIndex("product", table, ["name", "email"])
);

type product = typeof product.$inferSelect;

export default product;

Key Points:

  • columns: Custom fields for your domain
  • identifier: Adds id, createdAt, updatedAt, activated (from infrastructure)
  • pgIndex: Creates database index for performance
  • Type inference: TypeScript type automatically derived from schema

2.2 Validation Schemas (src/domain/product/schema.ts)

Zod schemas for runtime validation:

typescript
import { withPagination, zodIdentifier } from "@infrastructure/repositories/references";
import { headers } from "@infrastructure/server/interface";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { array, object } from "zod/v4";
import product from "./entity";

// Generate base schemas from Drizzle entity
const create = createInsertSchema(product, {
  name: (schema) => schema.min(1).max(50),
  price: (schema) => schema.min(1),
  description: (schema) => schema.min(1).max(400),
});

const select = createSelectSchema(product, {
  ...zodIdentifier,
}).partial();

// Define action-specific schemas
const actions = {
  headers,
  id: select.pick({ id: true }).required(),
  read: object({
    ...select.omit({ id: true }).shape,
    ...withPagination.shape,
  }),
  create: create.omit({ id: true }),
  update: create.omit({ id: true }).partial(),
  delete: select.pick({ id: true }),
};

export default { actions, entity: array(select) };

Key Points:

  • createInsertSchema: Generates Zod schema from Drizzle table
  • Custom validation: Override default rules (e.g., email format, password length)
  • Action schemas: Separate schemas for each operation
  • Type-safe: Full TypeScript inference

2.3 Routes Definition (src/domain/product/routes.ts)

Fastify routes with OpenAPI documentation:

typescript
import request from "@infrastructure/server/request";
import type { FastifyInstance } from "fastify";
import deleteEntity from "./actions/delete-entity";
import getById from "./actions/get-by-id";
import getFindByParams from "./actions/get-find-by-params";
import postNewEntity from "./actions/post-new-entity";
import putUpdateEntity from "./actions/put-update-entity";
import schema from "./schema";

export default async function productRoutes(api: FastifyInstance) {
  api.get("/ping", { schema: { tags: ["Product"] } }, (_, reply) => reply.code(200).send());

  api.get(
    "/:id",
    {
      schema: {
        tags: ["Product"],
        summary: "Find product by id",
        params: schema.actions.id,
        headers: schema.actions.headers,
        response: { 200: schema.entity, ...request.reply.schemas },
      },
    },
    request.restricted(getById),
  );

  api.post(
    "/",
    {
      schema: {
        tags: ["Product"],
        summary: "Create new product",
        body: schema.actions.create,
        headers: schema.actions.headers,
        response: { 201: schema.entity, ...request.reply.schemas },
      },
    },
    request.restricted(postNewEntity),
  );

  // ... PUT and DELETE routes
}

Key Points:

  • schema.tags: Groups endpoints in Swagger UI
  • schema.params/body/headers: Automatic validation
  • request.restricted(): Applies authentication middleware
  • response: Defines response schema for documentation

2.4 Business Logic (src/domain/product/actions/post-new-entity.ts)

Create product action with validation and caching:

typescript
import cache from "@infrastructure/cache/actions";
import { hash, tag } from "@infrastructure/repositories/references";
import repository from "@infrastructure/repositories/repository";
import { container } from "@infrastructure/server/request";
import identity from "../entity";
import { default as schema } from "../schema";
import getById from "./get-by-id";

export default async function postNewEntity(request: container) {
  request.status(201);

  // 1. Validate request body
  const validRequest = await schema.actions.create.safeParseAsync(request.body());
  if (!validRequest.success) {
    throw request.badRequest(request.language(), "post/product/{params}");
  }

  const content = await repository
    .insert(user)
    .values({
      ...validRequest.data,
      
    })
    .onConflictDoNothing()
    .returning();

  // 3. Check if insert succeeded
  if (!content.length) {
    throw request.unprocessableEntity(request.language(), `post/product/${validRequest.data.email}`);
  }

  // 4. Invalidate cache (all product list queries)
  await cache.json.del(tag("product", "find*"));

  // 5. Return created product (fetched to ensure cache consistency)
  return getById(new container({ params: { id: content[0].id } }));
}

Flow Diagram

POST /api/v1/identitiespostNewEntity()Validate bodyValid dataINSERT identityCreated identityInvalidate keysIdentity data201 Created

Step 3: Request Flow Example

Let's trace a complete request:

Creating a Product

Request:

bash
curl -X POST http://localhost:3000/api/v1/products \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer token" \
  -d '{
    "name": "John",
    "lastName": "Doe",
    "email": "john@example.com",
    "password": "SecurePass123"
  }'

What Happens:

  1. Fastify receives request → Routes to userRoutes
  2. Schema validation → Validates body against schema.actions.create
  3. Authenticationrequest.restricted() checks token
  4. Action execution → Calls postNewEntity()
  5. Password hashinghash() encrypts password
  6. Database insert → Drizzle ORM inserts record
  7. Cache invalidation → Clears cached product lists
  8. Response → Returns created product (201 status)

Response:

json
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "name": "John",
  "lastName": "Doe",
  "email": "john@example.com",
  "activated": true,
  "createdAt": "2024-01-01T00:00:00.000Z",
  "updatedAt": "2024-01-01T00:00:00.000Z"
}

Retrieving a Product

Request:

bash
curl http://localhost:3000/api/v1/products/123e4567-e89b-12d3-a456-426614174000 \
  -H "Authorization: Bearer token"

Flow with Caching:

HitMiss

Code (get-by-id.ts):

typescript
export default async function getById(request: container) {
  request.status(200);

  // Validate ID parameter
  const validRequest = await schema.actions.id.safeParseAsync(request.params());
  if (!validRequest.success) {
    throw request.badRequest(request.language(), tag("product", "find{id}"));
  }

  const { id } = validRequest.data;
  const reference = tag("product", "find{id}", { id });

  // Check cache first
  const cached = await cache.json.get<{ [key: string]: typeof user[] }>(reference);
  if (cached?.[reference]) return cached[reference];

  // Query database if not cached
  const prepare = repository
    .select()
    .from(user)
    .where(eq(user.id, sql.placeholder("id")))
    .limit(1)
    .orderBy(desc(user.id))
    .prepare("/user/id");

  const content = await prepare.execute({ id });

  if (!content.length) {
    throw request.notFound(request.language(), tag("product", "find{id}"));
  }

  // Cache for 10 minutes
  await cache.json.set(reference, content, 60 * 10);

  return content;
}

Step 4: Customizing the Domain

Adding Custom Fields

Edit entity.ts:

typescript
const columns = {
  name: varchar("name", { length: 50 }).notNull(),
  price: decimal("price", { precision: 10, scale: 2 }).notNull(),
  description: varchar("description", { length: 400 }),
  
  // Add new fields
  stock: integer("stock").notNull(),
  category: varchar("category", { length: 20 }).notNull(),
  perishable: boolean("perishable").notNull(),
};

Enhancing Validation

Edit schema.ts:

typescript
const create = createInsertSchema(user, {
  name: (schema) => schema.min(1).max(50),
  price: (schema) => schema.min(0),
  description: (schema) => schema.max(400),
  // Add custom validation
  stock: (schema) => schema.min(0),
  category: (schema) => schema.enum(["Fruits", "Vegetables", "Meats", "Hygiene"]),
});

Running Migrations

After modifying the entity:

bash
# Generate migration
bun db:migrate

# Apply to database
bun db:migrate:push
bash
# Generate migration
npm run db:migrate

# Apply to database
npm run db:migrate:push
bash
# Generate migration
yarn db:migrate

# Apply to database
yarn db:migrate:push
bash
# Generate migration
pnpm db:migrate

# Apply to database
pnpm db:migrate:push

Step 5: Testing the Domain

Write tests for your actions:

typescript
// tests/unit/domain/product/product-create.spec.ts
import { describe, it, expect, beforeEach } from "bun:test";
import { createProductBuilder } from "@tests/builders/product.builder";
import postNewEntity from "@domain/product/actions/post-new-entity";

describe("Product Creation", () => {
  it("should create product with hashed password", async () => {
    const data = { name: "Product", price: 10, description: "Description", stock: 10, category: "Fruits", perishable: true }
    const productData = createProductBuilder(data);
    const result = await postNewEntity(mockRequest(productData));
    expect(result[0].name).toBe("Product");
    expect(result[0].category).toBe("Fruits");
    expect(result[0].perishable).toBe(true);
  });

  it("should reject invalid email", async () => {
    const data = { name: "Product", price: 10, description: "Description", stock: 10, category: "Fruits", perishable: true }
    const productData = createProductBuilder(data);
    await expect(postNewEntity(mockRequest(productData))).rejects.toThrow();
  });
});

Architecture Benefits Demonstrated

This example shows:

  1. Separation of Concerns: Entity, validation, routes, and logic are separate
  2. Type Safety: Full TypeScript inference from database to API
  3. Caching: Automatic Redis caching with invalidation
  4. Validation: Request/response validation at multiple levels
  5. Documentation: Auto-generated Swagger docs from schemas
  6. Testability: Easy to mock and test each layer
  7. Scalability: Add new domains without affecting existing ones

Next Steps


See Also: