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¶
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¶
- Mock API Pattern - Adapter implementation
- Database Schema - Repository patterns
- API Reference - Application layer APIs