Skip to main content

Backend Module Architecture

Standard Module Structure

All feature modules in /backend/modules/ follow a consistent architecture pattern. This ensures code is organized, maintainable, and easy to navigate.

Typical Module Directory

/backend/modules/[module-name]/
├── routes.js # Express router with endpoint definitions
├── repository.js # Data access layer (Sequelize queries)
├── operations/ # Business logic operations (optional)
│ ├── create.js
│ ├── update.js
│ ├── delete.js
│ └── ...
├── queries/ # Complex query builders (optional)
├── core/ # Core utilities (optional)
│ ├── serializers.js # Format data for API responses
│ ├── parsers.js # Parse request data
│ └── builders.js # Build database objects
├── middleware/ # Route-specific middleware (optional)
│ └── access.js # Access control
└── utils/ # Module-specific utilities
└── validation.js # Input validation

Note: Not all modules have all directories. Simpler modules may only have routes.js and basic logic.


Example: Tasks Module (Complex)

The tasks module (/backend/modules/tasks/) is the most comprehensive example, showing the full potential of the module pattern:

/backend/modules/tasks/
├── routes.js # Main routes: GET /tasks, POST /task, etc.
├── repository.js # Data access: findTaskById, findTasksForUser
├── recurringTaskService.js # Recurring task pattern logic (8.5KB)
├── taskEventService.js # Task activity logging
├── taskScheduler.js # Cron-based task scheduling with node-cron

├── operations/ # Business logic operations
│ ├── list.js # List operations and filtering
│ ├── completion.js # Task completion/status changes
│ ├── recurring.js # Recurrence pattern handling
│ ├── subtasks.js # Subtask CRUD operations
│ ├── tags.js # Tag assignment to tasks
│ ├── grouping.js # Task grouping logic
│ ├── sorting.js # Sort order logic
│ └── parent-child.js # Parent-child relationship handling

├── queries/
│ ├── query-builders.js # filterTasksByParams, buildWhereClause
│ ├── metrics-queries.js # Task metrics and analytics queries
│ └── metrics-computation.js # Metric calculations and aggregations

├── core/
│ ├── serializers.js # serializeTask, serializeTasks
│ ├── builders.js # buildTaskAttributes for create/update
│ ├── parsers.js # parseTaskInput from requests
│ └── comparators.js # Detect task changes for audit log

├── middleware/
│ └── access.js # Task-specific access control

└── utils/
├── constants.js # Task-specific constants (status codes, etc.)
├── validation.js # Task input validation rules
└── logging.js # Change tracking helpers

Example: Projects Module (Simpler)

The projects module (/backend/modules/projects/) follows the same pattern but with less complexity:

/backend/modules/projects/
├── routes.js # Project CRUD endpoints
├── repository.js # Project data access (findAll, findById, create, etc.)
└── utils/
└── validation.js # Project validation (name required, etc.)

This shows that modules scale based on complexity - simple features don't require the full directory structure.


Module Pattern Details

routes.js - Express Router

Purpose: Define HTTP endpoints for the module

Pattern:

// /backend/modules/[module]/routes.js
const express = require('express');
const router = express.Router();
const repository = require('./repository');
const { hasAccess } = require('../../middleware/authorize');

// List all resources (collection)
router.get('/[resources]', async (req, res, next) => {
try {
const items = await repository.findAll(req.currentUser.id, req.query);
res.json(items);
} catch (error) {
next(error); // Pass to global error handler
}
});

// Create new resource (singular)
router.post('/[resource]', async (req, res, next) => {
try {
const item = await repository.create(req.body, req.currentUser.id);
res.status(201).json(item);
} catch (error) {
next(error);
}
});

// Get single resource (with authorization)
router.get('/[resource]/:id',
hasAccess('ro', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
const item = await repository.findById(req.params.id, req.currentUser.id);
if (!item) {
return res.status(404).json({ error: 'Not found' });
}
res.json(item);
} catch (error) {
next(error);
}
}
);

// Update resource (with authorization)
router.put('/[resource]/:id',
hasAccess('rw', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
const updated = await repository.update(req.params.id, req.body, req.currentUser.id);
res.json(updated);
} catch (error) {
next(error);
}
}
);

// Delete resource
router.delete('/[resource]/:id',
hasAccess('rw', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
await repository.destroy(req.params.id, req.currentUser.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
);

module.exports = router;

Key Conventions:

  • Plural for collections: GET /tasks
  • Singular for single resource: GET /task/:id, POST /task
  • Always use try/catch
  • Pass errors to next(error) for global error handler
  • Use hasAccess() middleware for authorization
  • Return proper HTTP status codes (200, 201, 204, 404, etc.)

repository.js - Data Access Layer

Purpose: Abstract database queries from routes

Pattern:

// /backend/modules/[module]/repository.js
const { Model } = require('../../models');

async function findAll(userId, filters = {}) {
return await Model.findAll({
where: {
user_id: userId,
...buildWhereClause(filters)
},
include: [...],
order: [['created_at', 'DESC']]
});
}

async function findById(id, userId) {
return await Model.findOne({
where: { id, user_id: userId },
include: [...]
});
}

async function create(data, userId) {
return await Model.create({
...data,
user_id: userId
});
}

async function update(id, data, userId) {
const instance = await findById(id, userId);
if (!instance) {
throw new NotFoundError('Resource not found');
}
return await instance.update(data);
}

async function destroy(id, userId) {
const instance = await findById(id, userId);
if (!instance) {
throw new NotFoundError('Resource not found');
}
await instance.destroy();
}

module.exports = {
findAll,
findById,
create,
update,
destroy
};

Why Repository Pattern:

  • Separates data access from business logic
  • Makes testing easier (can mock repository)
  • Centralizes query logic
  • Prevents Model usage directly in routes

core/serializers.js - Response Formatting

Purpose: Transform database objects into API-friendly format

Pattern:

// /backend/modules/[module]/core/serializers.js

function serializeItem(item) {
if (!item) return null;

return {
id: item.id,
uid: item.uid,
name: item.name,
description: item.description,
created_at: item.created_at,
updated_at: item.updated_at,
// Include associations if loaded
tags: item.Tags ? item.Tags.map(serializeTag) : undefined,
user: item.User ? serializeUser(item.User) : undefined
};
}

function serializeItems(items) {
return items.map(serializeItem);
}

module.exports = {
serializeItem,
serializeItems
};

Usage in routes:

const { serializeItem, serializeItems } = require('./core/serializers');

router.get('/items', async (req, res) => {
const items = await repository.findAll(req.currentUser.id);
res.json(serializeItems(items)); // Transform before sending
});

core/builders.js - Object Construction

Purpose: Build objects for database creation/update from request data

Pattern:

// /backend/modules/[module]/core/builders.js

function buildItemAttributes(data, userId) {
const attributes = {
user_id: userId,
name: data.name?.trim(),
description: data.description?.trim() || null
};

// Optional fields
if (data.due_date) {
attributes.due_date = parseDate(data.due_date);
}

if (data.priority !== undefined) {
attributes.priority = parseInt(data.priority, 10);
}

return attributes;
}

module.exports = { buildItemAttributes };

Usage:

const { buildItemAttributes } = require('./core/builders');

router.post('/item', async (req, res) => {
const attributes = buildItemAttributes(req.body, req.currentUser.id);
const item = await repository.create(attributes);
res.status(201).json(serializeItem(item));
});

operations/ - Business Logic

Purpose: Complex operations that don't fit in simple CRUD

Example: operations/completion.js (from tasks module)

// /backend/modules/tasks/operations/completion.js

async function completeTask(taskId, userId, completionData) {
// Get task with associations
const task = await repository.findById(taskId, userId);

if (task.recurrence_type) {
// Handle recurring task completion
await recurringTaskService.handleCompletion(task, completionData);
} else {
// Simple completion
task.status = 2; // completed
task.completed_at = new Date();
await task.save();
}

// Log event
await taskEventService.logEvent(taskId, 'completed', userId);

// Update related subtasks
if (completionData.completeSubtasks) {
await completeSubtasks(taskId);
}

return task;
}

module.exports = { completeTask };

Module Communication

Accessing Other Modules

Modules can import other module repositories and services:

// In /backend/modules/projects/routes.js

// Import task repository from tasks module
const taskRepository = require('../tasks/repository');

// Import shared service
const permissionsService = require('../../services/permissionsService');

router.get('/project/:id/tasks', async (req, res) => {
// Use task repository
const tasks = await taskRepository.findTasksForProject(req.params.id);
res.json(tasks);
});

Avoid Circular Dependencies

Bad:

// Module A imports Module B
const moduleB = require('../moduleB');

// Module B imports Module A
const moduleA = require('../moduleA'); // CIRCULAR!

Good:

// Extract shared logic to /backend/services/
// Both modules import from services
const sharedService = require('../../services/sharedService');

How to Add a New Module

Example: Creating a "labels" module

Step 1: Create Directory Structure

mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels/utils

Step 2: Create routes.js

// /backend/modules/labels/routes.js
const express = require('express');
const router = express.Router();
const repository = require('./repository');

router.get('/labels', async (req, res, next) => {
try {
const labels = await repository.findAll(req.currentUser.id);
res.json(labels);
} catch (error) {
next(error);
}
});

router.post('/label', async (req, res, next) => {
try {
const label = await repository.create(req.body, req.currentUser.id);
res.status(201).json(label);
} catch (error) {
next(error);
}
});

router.put('/label/:id', async (req, res, next) => {
try {
const label = await repository.update(req.params.id, req.body, req.currentUser.id);
res.json(label);
} catch (error) {
next(error);
}
});

router.delete('/label/:id', async (req, res, next) => {
try {
await repository.destroy(req.params.id, req.currentUser.id);
res.status(204).send();
} catch (error) {
next(error);
}
});

module.exports = router;

Step 3: Create repository.js

// /backend/modules/labels/repository.js
const { Label } = require('../../models');

async function findAll(userId) {
return await Label.findAll({
where: { user_id: userId },
order: [['name', 'ASC']]
});
}

async function findById(id, userId) {
return await Label.findOne({
where: { id, user_id: userId }
});
}

async function create(data, userId) {
return await Label.create({
name: data.name,
color: data.color || '#gray',
user_id: userId
});
}

async function update(id, data, userId) {
const label = await findById(id, userId);
if (!label) {
throw new Error('Label not found');
}
return await label.update({
name: data.name,
color: data.color
});
}

async function destroy(id, userId) {
const label = await findById(id, userId);
if (!label) {
throw new Error('Label not found');
}
await label.destroy();
}

module.exports = {
findAll,
findById,
create,
update,
destroy
};

Step 4: Create Model

See Database documentation for creating models and migrations.

Step 5: Register Routes in app.js

Edit /backend/app.js:

// Add with other module registrations (around line 50-70)

// Labels module
app.use('/api/v1', require('./modules/labels/routes'));
app.use('/api', require('./modules/labels/routes')); // Backward compatibility

Step 6: Add Swagger Documentation

Edit /backend/config/swagger.js to add Label schema:

components: {
schemas: {
// ... existing schemas ...
Label: {
type: 'object',
properties: {
id: { type: 'integer' },
uid: { type: 'string' },
name: { type: 'string' },
color: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' }
}
}
}
}

Step 7: Write Tests

Create /backend/tests/integration/labels/labels.test.js:

const request = require('supertest');
const app = require('../../../app');
const { Label, User } = require('../../../models');

describe('Labels API', () => {
let user, authCookie;

beforeEach(async () => {
user = await User.create({
email: 'test@example.com',
password: 'password123'
});

const res = await request(app)
.post('/api/login')
.send({ email: 'test@example.com', password: 'password123' });
authCookie = res.headers['set-cookie'];
});

afterEach(async () => {
await Label.destroy({ where: {} });
await User.destroy({ where: {} });
});

it('should create label', async () => {
const response = await request(app)
.post('/api/v1/label')
.set('Cookie', authCookie)
.send({ name: 'Important', color: '#red' });

expect(response.status).toBe(201);
expect(response.body.name).toBe('Important');
});

it('should list user labels', async () => {
await Label.create({ name: 'Label 1', user_id: user.id });
await Label.create({ name: 'Label 2', user_id: user.id });

const response = await request(app)
.get('/api/v1/labels')
.set('Cookie', authCookie);

expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
});
});

Step 8: Create Frontend Integration (Optional)

Create /frontend/utils/labelsService.ts and components as needed.


Module Checklist

When adding a new module, ensure:

  • Directory created in /backend/modules/[name]/
  • routes.js with all necessary endpoints
  • repository.js with data access methods
  • Model created (if new database table needed)
  • Migration created and run
  • Routes registered in /backend/app.js
  • Swagger schema added
  • Integration tests written
  • Documentation updated (if public feature)