Info PPAAI App

Architecture Overview

Overview of the Info PPAAI App architecture and design decisions

Architecture Overview

This document outlines the key architectural decisions and technology choices for the Info PPAAI App.

Tech Stack

The application is built as a monorepo using the following technologies:

  • Framework: Next.js 16
  • Language: TypeScript
  • Package Manager: Bun
  • Monorepo Tool: Turborepo
  • Database: PostgreSQL with Drizzle ORM
  • Authentication: Better Auth
  • API: tRPC
  • Code Quality: Biome (linting & formatting)
  • Testing: Bun Test with Happy-DOM
  • Documentation: Fumadocs

Project Structure

info-ppaai-app/
├── apps/
│   └── web/          # Next.js web application
│       ├── src/
│       │   ├── app/                    # Next.js App Router pages
│       │   ├── components/             # React components
│       │   │   ├── organizations/      # Organization-specific components
│       │   │   └── ui/                 # Reusable UI components
│       │   ├── hooks/                  # Custom React hooks
│       │   ├── lib/                    # Utility functions
│       │   └── utils/                  # tRPC and query client setup
└── packages/
    ├── api/          # tRPC API definitions
    │   └── src/
    │       ├── modules/                # Feature modules
    │       │   └── policy/             # Policy module
    │       │       ├── application/    # Use cases (business logic)
    │       │       ├── infrastructure/ # Data access (repositories)
    │       │       ├── presentation/   # API controllers (tRPC routers)
    │       │       └── policy.module.ts # Dependency Injection
    │       └── routers/                # Root router configuration
    ├── auth/         # Better Auth configuration
    ├── config/       # Shared TypeScript configuration
    ├── db/           # Database schema and migrations
    │   └── src/
    │       ├── schema/                 # Drizzle ORM schemas
    │       └── migrations/             # Database migrations
    └── docs/         # Documentation package

Clean Architecture

The backend follows Clean Architecture principles, organizing code into distinct layers with clear dependencies:

Layer Structure

┌─────────────────────────────────────┐
│   Presentation Layer                │
│   (Controllers/tRPC Routers)        │
│   - HTTP/API interface              │
│   - Input validation (Zod)          │
│   - Request/response handling       │
└────────────┬────────────────────────┘
             │ depends on

┌─────────────────────────────────────┐
│   Application Layer                 │
│   (Use Cases)                       │
│   - Business logic                  │
│   - Orchestration                   │
│   - Single responsibility           │
└────────────┬────────────────────────┘
             │ depends on

┌─────────────────────────────────────┐
│   Infrastructure Layer              │
│   (Repositories)                    │
│   - Database access                 │
│   - External services               │
│   - Data persistence                │
└────────────┬────────────────────────┘
             │ uses

┌─────────────────────────────────────┐
│   Database Layer                    │
│   (Drizzle ORM + PostgreSQL)        │
└─────────────────────────────────────┘

Benefits

  • Testability: Each layer can be tested in isolation
  • Maintainability: Clear separation of concerns
  • Flexibility: Implementation details (like database) can be swapped without affecting business logic

Module Pattern

To manage dependencies between layers effectively, we use a Module Pattern inspired by NestJS.

Why use Modules?

In Clean Architecture, higher layers (Presentation) depend on lower layers (Application, Infrastructure). Manually instantiating these dependencies in every controller leads to code duplication and makes testing harder.

The Module Pattern solves this by:

  1. Centralizing Dependency Injection: A single [Feature]Module class instantiates all repositories and use cases.
  2. Decoupling Controllers: Controllers receive the module as an argument, rather than creating dependencies themselves.
  3. Simplifying Testing: You can easily pass a mock module to the controller during tests.

Module Structure

Each feature module (e.g., policy) exports a Module class that acts as a container:

// packages/api/src/modules/policy/policy.module.ts
export class PolicyModule {
  // Infrastructure
  public readonly policyRepository: PolicyRepository;

  // Application (Use Cases)
  public readonly createPolicyUseCase: CreatePolicyUseCase;
  public readonly getPolicyUseCase: GetPolicyUseCase;
  public readonly getPolicyBySlugUseCase: GetPolicyBySlugUseCase;
  // ... other use cases

  constructor() {
    // 1. Initialize Infrastructure
    this.policyRepository = new PolicyRepository();

    // 2. Initialize Application (injecting infrastructure)
    this.createPolicyUseCase = new CreatePolicyUseCase(
      this.policyRepository
    );
    this.getPolicyUseCase = new GetPolicyUseCase(
      this.policyRepository
    );
    this.getPolicyBySlugUseCase = new GetPolicyBySlugUseCase(
      this.policyRepository
    );
  }
}

The controller then uses a factory pattern to accept this module:

// packages/api/src/modules/policy/presentation/controllers/policy-controller/policy.controller.ts
export const createPolicyRouter = (module: PolicyModule) => {
  return router({
    create: protectedProcedure
      .input(
        z.object({
          title: z.string().min(1),
          slug: z.string().min(1),
          description: z.string().optional(),
        })
      )
      .mutation(({ input, ctx }) => {
        // Use the injected use case
        return module.createPolicyUseCase.execute({
          title: input.title,
          slug: input.slug,
          description: input.description,
          createdById: ctx.session.user.id,
        });
      }),
  });
};

Database Schema

UUID-Based Primary Keys

All tables use UUID primary keys with automatic generation:

export const organization = pgTable("organization", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  // ...
});

Relationships

Foreign keys enforce referential integrity with cascade deletes:

export const member = pgTable("member", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  organizationId: uuid("organization_id")
    .notNull()
    .references(() => organization.id, { onDelete: "cascade" }),
  role: text("role").notNull(),
});

Metadata Handling

Organization metadata is stored as JSON with safe parsing:

// Zod schema for validation
export const organizationMetadataSchema = z.object({
  description: z.string().optional(),
  website: z.url().optional(),
  industry: z.string().optional(),
  size: z.string().optional(),
  location: z.string().optional(),
}).loose();

// Safe parsing utility
export function parseOrganizationMetadata(
  metadata: string | null | undefined
): OrganizationMetadata {
  if (!metadata) return {};
  
  try {
    const parsed = JSON.parse(metadata);
    const result = organizationMetadataSchema.safeParse(parsed);
    return result.success ? result.data : {};
  } catch {
    return {};
  }
}

Frontend Architecture

React Query Integration

Custom hooks abstract tRPC calls and manage cache:

export function useOrganizations() {
  return trpc.organization.list.useQuery();
}

export function useCreateOrganization() {
  const utils = trpc.useUtils();
  
  return trpc.organization.create.useMutation({
    onSuccess: () => {
      // Invalidate cache to refetch organizations
      utils.organization.list.invalidate();
    },
  });
}

Server-Side Rendering

Organization pages use SSR with React Query hydration:

export default async function OrganizationsPage() {
  const trpc = await createServerCaller();
  const organizations = await trpc.organization.list();
  
  return (
    <HydrateClient state={dehydrate(getQueryClient())}>
      {/* Client components can access cached data */}
    </HydrateClient>
  );
}

Next Steps

For practical guidance on implementing features with this architecture, see the Development Guide which includes:

  • Step-by-step instructions for adding new modules
  • Code examples and patterns
  • Best practices and guidelines
  • Reference to the Organization Module implementation
  • Common patterns (pagination, filtering, transactions)
  • Troubleshooting tips