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.jswith all necessary endpoints -
repository.jswith 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)