Skip to main content

Backend Architecture

TamborraData’s backend implements a clean three-layer architecture: API Routes → Services → Repositories. This pattern provides clear separation between HTTP handling, business logic, and data access.

Layered Pattern

Key Principle: Each layer has a single responsibility and depends only on the layer below it.

API Route Layer

Location: app/(backend)/api/*/route.ts Responsibilities:
  • Handle HTTP requests/responses
  • Parse query parameters
  • Validate inputs using DTOs
  • Call services
  • Format responses
  • Handle errors
// app/(backend)/api/statistics/route.ts
import 'server-only';
import { NextResponse } from 'next/server';
import { getStatistics } from './services/statistics.service';
import { getSysStatus } from '../../shared/utils/getSysStatus';
import { checkParams } from './dtos/statistics.schema';

export async function GET(req: Request) {
  try {
    const year = new URL(req.url).searchParams.get('year');

    // 1. Validate parameters
    const { valid, cleanYear, error: paramError } = await checkParams(year);
    
    if (!valid) {
      return NextResponse.json({ error: paramError }, { status: 404 });
    }

    // 2. Check system status
    const isUpdating: boolean = await getSysStatus();
    
    if (isUpdating) {
      return NextResponse.json({ isUpdating: true }, { status: 200 });
    }

    // 3. Call service
    const { statistics, error } = await getStatistics(cleanYear);

    if (error && !statistics) {
      return NextResponse.json({ error }, { status: 500 });
    }

    // 4. Format response
    return NextResponse.json({
      isUpdating,
      year: cleanYear,
      total_categories: statistics ? Object.keys(statistics).length : 0,
      statistics,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Error al obtener el estado del sistema', details: error },
      { status: 500 }
    );
  }
}
Route handlers should NOT:
  • Contain business logic
  • Execute database queries
  • Transform data (beyond formatting responses)
  • Have complex conditionals

Service Layer

Location: app/(backend)/api/*/services/*.service.ts Responsibilities:
  • Implement business logic
  • Orchestrate repository calls
  • Transform and format data
  • Handle errors and logging
  • Apply business rules
// app/(backend)/api/statistics/services/statistics.service.ts
import 'server-only';
import { log } from '@/app/(backend)/core/logger';
import { fetchStatistics } from '../repositories/statistics.repo';
import { groupBy } from '@/app/(backend)/shared/utils/groupBy';
import { StatisticsType } from '../types';

export async function getStatistics(year: string): Promise<StatisticsType> {
  // 1. Fetch data from repository
  const { statistics, error } = await fetchStatistics(year);

  if (error && !statistics) {
    log(`getStatistics error for year ${year}: ${error}`, 'error');
    return { statistics: null, error };
  }

  // 2. Apply business logic: group by category
  const statsByCategory = groupBy(statistics, 'category');

  // 3. Return formatted data
  return { statistics: statsByCategory, error: null };
}
Services are the “brain” of the application. They contain all business logic and decision-making code.

Repository Layer

Location: app/(backend)/api/*/repositories/*.repo.ts Responsibilities:
  • Execute database queries
  • Abstract data access
  • Handle database errors
  • Return raw data
// app/(backend)/api/statistics/repositories/statistics.repo.ts
import 'server-only';
import { log } from '@/app/(backend)/core/logger';
import { supabaseClient } from '@/app/(backend)/core/db/supabaseClient';
import { FetchStatisticsType } from '../types';

export async function fetchStatistics(
  year: string
): Promise<FetchStatisticsType> {
  try {
    // Execute Supabase query
    const { data, error } = await supabaseClient
      .from('statistics')
      .select('category, public_data, summary')
      .eq('year', year)
      .order('public_data', { ascending: false })
      .limit(30);

    if (error) {
      log(`Error fetching statistics for year ${year}: ${error.message}`, 'error');
      return { statistics: null, error: 'Error de la base de datos' };
    }

    return { statistics: data ?? null, error: null };
  } catch (error) {
    log(`Error fetching statistics for year ${year}: ${JSON.stringify(error)}`, 'error');
    return { 
      statistics: null, 
      error: 'Error inesperado, por favor intente nuevamente más tarde' 
    };
  }
}
Repositories should NOT:
  • Contain business logic
  • Transform data (return raw database results)
  • Call other repositories
  • Make decisions based on data

DTO (Data Transfer Object) Layer

Location: app/(backend)/api/*/dtos/*.schema.ts Responsibilities:
  • Validate request parameters
  • Sanitize inputs
  • Return validation results
// app/(backend)/api/statistics/dtos/statistics.schema.ts
import 'server-only';
import { VALID_YEARS } from '@/app/(backend)/shared/constants/catalog';
import { CheckParamsType } from '../types';

export async function checkParams(
  year: string
): Promise<CheckParamsType> {
  // Check if year parameter exists
  if (!year) {
    return { 
      valid: false, 
      cleanYear: null, 
      error: "El parámetro 'year' es obligatorio" 
    };
  }

  // Clean whitespace
  const cleanYear = year.trim();

  // Validate format
  if (cleanYear !== 'global' && !/^\d{4}$/.test(cleanYear)) {
    return {
      valid: false,
      cleanYear: null,
      error: "El parámetro 'year' debe ser 'global' o un año válido de cuatro dígitos",
    };
  }

  // Validate against available years
  const validYears: string[] = await VALID_YEARS();
  if (!validYears.includes(year)) {
    return {
      valid: false,
      cleanYear: null,
      error: `Año inválido. Años válidos: ${validYears.slice(0, 4).join(', ')}, ...`,
    };
  }

  return { valid: true, cleanYear, error: null };
}
DTOs prevent SQL injection and validate business rules before data reaches the service layer.

Feature Structure

Each API feature follows this consistent structure:
app/(backend)/api/[feature]/
├── route.ts                    # HTTP handler (GET/POST/PUT/DELETE)
├── services/
│   └── [feature].service.ts    # Business logic
├── repositories/
│   └── [feature].repo.ts       # Data access
├── dtos/
│   └── [feature].schema.ts     # Validation
└── types/
    └── index.ts                # TypeScript types

Real Examples

api/statistics/
├── route.ts
├── services/
│   └── statistics.service.ts
├── repositories/
│   └── statistics.repo.ts
├── dtos/
│   └── statistics.schema.ts
└── types/
    └── index.ts

Shared Utilities

Location: app/(backend)/shared/ Shared utilities used across features:
shared/
├── utils/
│   ├── groupBy.ts              # Group array by key
│   └── getSysStatus.ts         # Get system status
└── constants/
    └── catalog.ts              # Shared constants
// shared/utils/groupBy.ts
export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
  return array.reduce((result, item) => {
    const groupKey = String(item[key]);
    if (!result[groupKey]) {
      result[groupKey] = [];
    }
    result[groupKey].push(item);
    return result;
  }, {} as Record<string, T[]>);
}

Database Client

Location: app/(backend)/core/db/supabaseClient.ts
import 'server-only';
import 'dotenv/config';
import { createClient } from '@supabase/supabase-js';

// Supabase configuration
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;

export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey);
The 'server-only' import ensures this code never runs on the client side.

Error Handling

Consistent error handling across all layers:
try {
  const { data, error } = await supabaseClient
    .from('table')
    .select('*');

  if (error) {
    log(`Database error: ${error.message}`, 'error');
    return { data: null, error: 'Error de la base de datos' };
  }

  return { data, error: null };
} catch (error) {
  log(`Unexpected error: ${error}`, 'error');
  return { data: null, error: 'Error inesperado' };
}

Testing Strategy

Test each layer independently:
// Service test with mocked repository
describe('getStatistics', () => {
  it('should group statistics by category', async () => {
    const mockRepo = {
      fetchStatistics: jest.fn().mockResolvedValue({
        statistics: mockData,
        error: null,
      }),
    };

    const result = await getStatistics('2024');
    
    expect(result.statistics).toHaveProperty('top-names');
    expect(result.error).toBeNull();
  });
});
Test layer interactions:
// API route integration test
describe('GET /api/statistics', () => {
  it('should return statistics for valid year', async () => {
    const response = await fetch('/api/statistics?year=2024');
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toHaveProperty('statistics');
    expect(data.year).toBe('2024');
  });
});
Test complete flows:
// End-to-end test with real database
describe('Statistics flow', () => {
  it('should fetch and display statistics', async () => {
    await page.goto('/statistics/2024');
    
    await page.waitForSelector('[data-testid="stats-table"]');
    
    const table = await page.$('[data-testid="stats-table"]');
    expect(table).toBeTruthy();
  });
});

Benefits of Repository Pattern

Abstraction

Repositories abstract database implementation. Switching from Supabase to another database only requires changing repositories.

Testability

Services can be tested by mocking repositories, without needing a real database.

Reusability

Same repository can be used by multiple services, avoiding code duplication.

Maintainability

Clear separation makes it easy to find and modify code. Changes in one layer don’t affect others.

Next Steps

Frontend

Learn about Server Components and React Query

Database

Explore PostgreSQL schema and RLS policies

Build docs developers (and LLMs) love