Skip to content

Layered Architecture

AccessALI follows a strict layered architecture pattern with clear separation of concerns.

Architecture Layers

graph TD
    P[Presentation Layer] --> A[Application Layer]
    A --> D[Domain Layer]
    D --> I[Infrastructure Layer]

    P1["React Server Components<br/>Client Components<br/>UI Library (shadcn/ui)"] -.-> P
    A1["Server Actions<br/>API Routes<br/>Middleware"] -.-> A
    D1["Use Cases<br/>Services<br/>Validators"] -.-> D
    I1["Repositories<br/>Adapters<br/>Mock/Real APIs"] -.-> I

1. Presentation Layer

Location: src/app/, src/components/

Responsibility: User interface and user interaction

Components:

  • React Server Components (default)
  • Client Components ('use client')
  • shadcn/ui primitives
  • Feature-specific components

Example:

// src/app/(dashboard)/dashboard/page.tsx
export default async function DashboardPage() {
  const data = await getDashboardData()
  return <DashboardView data={data} />
}

Rules:

  • No business logic
  • No direct database access
  • Calls Application Layer only
  • Server Components by default

2. Application Layer

Location: src/app/actions/, src/app/api/, src/middleware.ts

Responsibility: Application orchestration and HTTP handling

Components:

  • Server Actions (data mutations/queries)
  • API Routes (webhooks, file uploads)
  • Middleware (auth, rate limiting)

Example:

// src/app/actions/dashboard.ts
'use server'

import { auth } from '@/lib/auth'
import { getDashboardDataUseCase } from '@/lib/use-cases/dashboard'

export async function getDashboardData() {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  return getDashboardDataUseCase(session.user.id)
}

Rules:

  • Authenticate requests
  • Validate input (Zod schemas)
  • Call Domain Layer (use cases)
  • Handle errors
  • Revalidate cache

3. Domain Layer

Location: src/lib/use-cases/, src/lib/services/, src/lib/validators/

Responsibility: Business logic and domain rules

Components:

  • Use Cases (orchestrate business operations)
  • Services (email, file upload, notifications)
  • Validators (Zod schemas)

Example:

// src/lib/use-cases/dashboard.ts
import { getUserProperties } from '@/lib/repositories/property-repository'
import { sapAdapter } from '@/lib/adapters/sap-adapter'

export async function getDashboardDataUseCase(userId: string) {
  // Orchestrate multiple data sources
  const properties = await getUserProperties(userId)
  const contracts = await Promise.all(
    properties.map(p => sapAdapter.getContract(p.sapContractId))
  )

  // Business logic
  return {
    properties,
    contracts,
    totalValue: contracts.reduce((sum, c) => sum + c.totalAmount, 0)
  }
}

Rules:

  • Pure business logic
  • Framework-agnostic
  • No HTTP concerns
  • Calls Infrastructure Layer only

4. Infrastructure Layer

Location: src/lib/repositories/, src/lib/adapters/, src/lib/mocks/, src/lib/integrations/

Responsibility: Data persistence and external integrations

Components:

  • Repositories (Prisma database access)
  • Adapters (mock/real API switcher)
  • Mocks (development implementations)
  • Integrations (real API clients)

Example:

// src/lib/repositories/property-repository.ts
import { prisma } from '@/lib/prisma'

export async function getUserProperties(userId: string) {
  return prisma.property.findMany({
    where: { userId },
    include: { milestones: true }
  })
}

Rules:

  • Handle data access
  • Abstract external dependencies
  • Throw domain errors
  • No business logic

Layer Communication

Allowed Dependencies

Presentation → Application → Domain → Infrastructure

Forbidden Dependencies

  • Infrastructure → Domain ❌
  • Domain → Application ❌
  • Application → Presentation ❌

Why?

  • Testability: Test each layer independently
  • Flexibility: Swap implementations easily
  • Maintainability: Clear boundaries
  • Scalability: Separate concerns

Best Practices

Do's

  • ✅ Keep layers thin and focused
  • ✅ Use dependency injection
  • ✅ Return domain errors (not HTTP errors)
  • ✅ Validate at layer boundaries
  • ✅ Use TypeScript interfaces

Don'ts

  • ❌ Skip layers (bypass)
  • ❌ Mix layer responsibilities
  • ❌ Put business logic in repositories
  • ❌ Access database from use cases directly
  • ❌ Return Prisma types from repositories

Example: Complete Flow

// 1. PRESENTATION LAYER
// src/app/(dashboard)/payments/page.tsx
export default async function PaymentsPage() {
  const payments = await getPayments()
  return <PaymentsList payments={payments} />
}

// 2. APPLICATION LAYER
// src/app/actions/payments.ts
'use server'

export async function getPayments() {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  return getPaymentsUseCase(session.user.id)
}

// 3. DOMAIN LAYER
// src/lib/use-cases/payments.ts
export async function getPaymentsUseCase(userId: string) {
  const payments = await getUserPayments(userId)
  const sapPayments = await sapAdapter.getPayments(userId)

  // Business logic: merge and deduplicate
  return mergePayments(payments, sapPayments)
}

// 4. INFRASTRUCTURE LAYER
// src/lib/repositories/payment-repository.ts
export async function getUserPayments(userId: string) {
  return prisma.payment.findMany({
    where: { userId },
    orderBy: { dueDate: 'asc' }
  })
}

Testing Strategy

Unit Tests

Test each layer independently:

// Domain layer test
describe('getDashboardDataUseCase', () => {
  it('calculates total value correctly', async () => {
    // Mock infrastructure
    const mockProperties = [/* ... */]
    jest.mock('@/lib/repositories/property-repository')

    const result = await getDashboardDataUseCase('user-123')

    expect(result.totalValue).toBe(1000000)
  })
})

Integration Tests

Test layer interactions:

// Application → Domain → Infrastructure
describe('getDashboardData action', () => {
  it('returns dashboard data for authenticated user', async () => {
    // Use test database
    const data = await getDashboardData()

    expect(data.properties).toBeDefined()
    expect(data.contracts).toBeDefined()
  })
})

Learn More