CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

ai-unified-process/aiup-nestjs-nextjs

Deprecated — use the layer plugins. AI Unified Process for the NestJS/Drizzle + Next.js stack - migrations, implementation, tests

1.06x
Quality

93%

Does it follow best practices?

Impact

100%

1.06x

Average score across 3 eval scenarios

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

worked-example.mdskills/implement/references/

Worked example: products

Two use cases on one Product feature, end to end: UC-010 lists the available products (a read), UC-011 creates a product (a write). Every relative import ends in .js because the layout detection found NodeNext; drop the suffix where it did not.

Backend — read path (UC-010)

// src/products/products.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { DRIZZLE, type DrizzleDb } from '../database/drizzle.provider.js';
import { products } from '../database/schema.js';

@Injectable()
export class ProductsRepository {
  constructor(@Inject(DRIZZLE) private readonly db: DrizzleDb) {}

  // UC-010 BR-010: Out-of-stock products are excluded regardless of any category filter.
  async findAvailable(category?: string) {
    const filters = [eq(products.inStock, true)];
    if (category) filters.push(eq(products.category, category));
    return this.db.select().from(products).where(and(...filters));
  }
}
// src/products/dto/product.response.ts
export type ProductResponse = {
  id: number;
  name: string;
  category: string;
  price: number;
};
// src/products/products.service.ts
import { Injectable } from '@nestjs/common';
import { ProductsRepository } from './products.repository.js';
import type { ProductResponse } from './dto/product.response.js';

@Injectable()
export class ProductsService {
  constructor(private readonly repository: ProductsRepository) {}

  async listAvailable(category?: string): Promise<ProductResponse[]> {
    const rows = await this.repository.findAvailable(category);
    return rows.map((row) => ({
      id: row.id,
      name: row.name,
      category: row.category,
      price: row.price,
    }));
  }
}
// src/products/dto/list-products.query.ts
import { IsOptional, IsString } from 'class-validator';

export class ListProductsQuery {
  @IsOptional()
  @IsString()
  category?: string;
}
// src/products/products.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ListProductsQuery } from './dto/list-products.query.js';
import { ProductsService } from './products.service.js';
import type { ProductResponse } from './dto/product.response.js';

@Controller('products')
export class ProductsController {
  constructor(private readonly service: ProductsService) {}

  @Get()
  async list(@Query() query: ListProductsQuery): Promise<ProductResponse[]> {
    return this.service.listAvailable(query.category);
  }
}
// src/products/products.module.ts
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller.js';
import { ProductsRepository } from './products.repository.js';
import { ProductsService } from './products.service.js';

@Module({
  controllers: [ProductsController],
  providers: [ProductsService, ProductsRepository],
})
export class ProductsModule {}

Register the module in the application's root module.

Backend — write path (UC-011)

A body DTO, a domain error, and a repository insert. The DTO's decorators carry the rules the global ValidationPipe enforces; the rule that needs the database lives in the service.

// src/products/dto/create-product.body.ts
import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator';

export class CreateProductBody {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  name!: string;

  @IsString()
  category!: string;

  // UC-011 BR-001: The price is a whole number of cents and never negative.
  @IsInt()
  @Min(0)
  price!: number;
}
// src/products/products.repository.ts — added to the class above
  async findByName(name: string) {
    return (await this.db.select().from(products).where(eq(products.name, name)).limit(1))[0];
  }

  async insert(values: typeof products.$inferInsert) {
    const [row] = await this.db.insert(products).values(values).returning();
    return row;
  }
// src/products/products.service.ts — added to the class above
import { DuplicateNameError } from '../common/errors.js'; // the project's domain error
import type { CreateProductBody } from './dto/create-product.body.js';

  async create(body: CreateProductBody): Promise<ProductResponse> {
    // UC-011 BR-002: Product names are unique.
    if (await this.repository.findByName(body.name)) {
      throw new DuplicateNameError('A product with this name already exists.');
    }
    const row = await this.repository.insert({ ...body, inStock: true });
    return { id: row.id, name: row.name, category: row.category, price: row.price };
  }
// src/products/products.controller.ts — added to the class above
import { Body, HttpCode, Post } from '@nestjs/common';
import { CreateProductBody } from './dto/create-product.body.js';

  @Post()
  @HttpCode(201)
  async create(@Body() body: CreateProductBody): Promise<ProductResponse> {
    return this.service.create(body);
  }

The message is the one the use case's alternative flow states. The project's exception filter maps DuplicateNameError to its status code (typically 409) and error body; when the project has no domain errors yet, throw Nest's ConflictException with the same message instead of inventing a hierarchy.

Frontend

Where the detection found no route indirection, the route file holds the page:

// src/app/products/page.tsx
'use client';

import { useEffect, useState } from 'react';

type Product = { id: number; name: string; category: string; price: number };

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [category, setCategory] = useState('');

  useEffect(() => {
    const query = category ? `?category=${encodeURIComponent(category)}` : '';
    fetch(`/api/products${query}`)
      .then((res) => res.json())
      .then(setProducts);
  }, [category]);

  return (
    <main>
      <h1>Products</h1>
      <label htmlFor="category">Category</label>
      <select id="category" value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="">All</option>
        <option value="tools">Tools</option>
      </select>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} — {product.price}
          </li>
        ))}
      </ul>
    </main>
  );
}

Where the detection found route indirection, the route file is a thin wrapper and the body above lives in the view component instead:

// src/app/products/page.tsx
'use client';
import { ProductsPage } from '../../views/ProductsPage';

export default function Page() {
  return <ProductsPage />;
}

skills

.mcp.json

LICENSE

README.md

tile.json