π οΈ Weekend Project: Databases
Everything you learned this module comes together in one build. Over a focused weekend you'll create TaskForge, a task manager that deliberately uses three different data stores β MongoDB, PostgreSQL/MySQL, and Redis β each doing what it's best at. This is a guided build with clear milestones, a checklist to track your progress, and a rubric so you know when you're done.
π― Learning Objectives
By the end of this project, you will be able to:
- Design a polyglot-persistence architecture and justify which store owns which data
- Wire an Express backend to MongoDB (Mongoose), a relational database, and Redis at once
- Keep data consistent across stores using a source-of-truth + rollback pattern
- Apply a cache-aside strategy with sensible TTLs and explicit invalidation
- Self-assess your build against a concrete "what good looks like" rubric
Estimated Time: 8β12 hours over a weekend β’ Difficulty: Intermediate
Hands-on: Build TaskForge end to end, following the four milestones and ticking off the checklist as you go.
In This Lesson
What You're Building
TaskForge is a small team task manager β think a stripped-down Trello or Jira. Users register and log in, create tasks, assign them to teammates, comment, and get notified. That sounds like a job for one database, and honestly, for a real MVP it would be. But the point of this weekend is to feel the seams between database paradigms by deliberately spreading the data across three stores that each shine at a different job.
This mirrors polyglot persistence β the real-world practice of choosing a data store per workload rather than forcing everything into one engine. Netflix, Uber, and Airbnb all run several database technologies in production for exactly this reason.
π Key Terms
Polyglot persistence: using multiple database technologies within one application, each chosen for the data it stores best.
Source of truth: the one store that authoritatively owns a piece of data; other stores may hold copies, but this one wins on conflict.
Cache-aside: a caching pattern where the app checks the cache first, falls back to the database on a miss, then populates the cache.
β οΈ A word of honesty before you start
Three databases for a task app is over-engineering for a real MVP β a single PostgreSQL instance (with a jsonb column for flexible fields) would ship faster and be easier to operate. We're doing it anyway because the goal is learning: by the end you'll have earned the judgment to know when polyglot persistence is worth its cost, and when it isn't.
The Architecture & Why Three Stores
Each store has a clear job. Read this table until the "why" for each is obvious β every design decision in the milestones flows from it.
| Store | Owns (source of truth) | Why it fits |
|---|---|---|
| MongoDB (documents) | Task detail, user profiles, comments | Flexible schema for fields that vary by task type; nested arrays (tags, attachments) map cleanly to documents |
| PostgreSQL / MySQL (relational) | Teams, memberships, permissions, audit log | ACID transactions and foreign keys guarantee integrity where relationships and consistency matter most |
| Redis (in-memory) | Sessions, cache, notification queue, counters | Microsecond reads, TTL expiry, and built-in list/set structures β ideal for ephemeral, high-churn data |
π‘ Setup shortcut
Don't install three databases by hand. A single docker-compose.yml spins them all up in one command. This lets you focus the weekend on code, not ops.
# docker-compose.yml
services:
mongo:
image: mongo:7
ports: ["27017:27017"]
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: taskforge
POSTGRES_DB: taskforge
ports: ["5432:5432"]
redis:
image: redis:7
ports: ["6379:6379"]
Run docker compose up -d and all three are live.
Milestone 1 β Scaffold & Connect
Goal: a running Express server that successfully connects to all three databases on startup and logs three green checkmarks. If a connection fails, the process should exit loudly β silent failures are the enemy of a multi-store app.
Create the project
mkdir taskforge && cd taskforge
npm init -y
npm install express mongoose pg redis dotenv cors helmet jsonwebtoken bcrypt uuid
npm install --save-dev nodemon
mkdir -p src/{config,models,services,controllers,routes,middleware}
touch .env .gitignore
Use pg (node-postgres) for PostgreSQL. If you prefer MySQL, swap in mysql2 β the query shape is nearly identical; only the placeholder syntax differs ($1 for Postgres vs ? for MySQL).
Connection modules
The modern redis v4+ and mongoose v8 clients are promise-native β no more promisify wrappers like older tutorials show.
// src/config/db.js
import mongoose from 'mongoose';
import pg from 'pg';
import { createClient } from 'redis';
// --- MongoDB ---
export async function connectMongo() {
await mongoose.connect(process.env.MONGO_URI);
console.log('β
MongoDB connected');
}
// --- PostgreSQL (connection pool) ---
export const pgPool = new pg.Pool({ connectionString: process.env.PG_URI });
export async function connectPostgres() {
const client = await pgPool.connect();
await client.query('SELECT 1');
client.release();
console.log('β
PostgreSQL connected');
}
// --- Redis ---
export const redis = createClient({ url: process.env.REDIS_URI });
redis.on('error', (err) => console.error('Redis error:', err));
export async function connectRedis() {
await redis.connect();
console.log('β
Redis connected');
}
// src/app.js
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { connectMongo, connectPostgres, connectRedis } from './config/db.js';
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
async function start() {
try {
await Promise.all([connectMongo(), connectPostgres(), connectRedis()]);
app.listen(3000, () => console.log('π TaskForge on http://localhost:3000'));
} catch (err) {
console.error('β Startup failed:', err);
process.exit(1); // fail loud, fail fast
}
}
start();
β Milestone 1 done whenβ¦
npm run dev prints all three green checkmarks and curl localhost:3000/health returns {"status":"ok"}. Kill one container and confirm the server refuses to start β that's the fail-loud behaviour working.
Milestone 2 β Data Models
Goal: define the shape of your data in each store. This is where the source-of-truth decisions from the architecture table become concrete.
MongoDB (Mongoose) β task detail & profiles
MongoDB owns the rich, variable parts: full task descriptions, tags, attachments, and profile preferences that differ per user.
// src/models/task.js
import mongoose from 'mongoose';
const taskSchema = new mongoose.Schema({
taskId: { type: String, required: true, unique: true, index: true }, // shared UUID
title: { type: String, required: true },
description: String,
status: { type: String, enum: ['backlog','todo','in_progress','in_review','done'], default: 'todo' },
priority: { type: String, enum: ['low','medium','high','urgent'], default: 'medium' },
assigneeId: String,
creatorId: String,
teamId: String,
dueDate: Date,
tags: [String],
attachments: [{ name: String, url: String, size: Number }],
customFields: mongoose.Schema.Types.Mixed // flexible per task type
}, { timestamps: true });
export const Task = mongoose.model('Task', taskSchema);
PostgreSQL β teams, permissions & the audit trail
Relationships and guaranteed writes live here. Note the foreign keys β the relational store enforces integrity that MongoDB would leave to application code.
-- src/config/schema.sql
CREATE TABLE IF NOT EXISTS users (
user_id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS teams (
team_id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_by UUID NOT NULL REFERENCES users(user_id)
);
CREATE TABLE IF NOT EXISTS team_memberships (
team_id UUID NOT NULL REFERENCES teams(team_id),
user_id UUID NOT NULL REFERENCES users(user_id),
role VARCHAR(10) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin','member','guest')),
PRIMARY KEY (team_id, user_id)
);
-- Lightweight task reference; full detail lives in MongoDB
CREATE TABLE IF NOT EXISTS tasks (
task_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
team_id UUID NOT NULL REFERENCES teams(team_id),
assignee_id UUID REFERENCES users(user_id),
status VARCHAR(15) NOT NULL DEFAULT 'todo',
created_by UUID NOT NULL REFERENCES users(user_id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
log_id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(20) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(20) NOT NULL,
actor_id UUID NOT NULL REFERENCES users(user_id),
changes JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
Redis β key conventions
Redis has no schema, but you still design its key namespace. Consistent, colon-delimited keys keep the store readable and let you use pattern scans.
| Key pattern | Type | Purpose | TTL |
|---|---|---|---|
session:{sessionId} | string (JSON) | Logged-in session data | 1 hour |
task:{taskId} | string (JSON) | Cached task summary | 5 min |
notifications:queue | list | Pending notifications | none |
user:notif:count:{userId} | integer | Unread badge counter | none |
β Milestone 2 done whenβ¦
Your Mongoose model loads without error, schema.sql runs cleanly against Postgres (verify the tables with \dt in psql), and you've documented your Redis key conventions in the README.
Milestone 3 β Create a Task Across Stores
Goal: the heart of the project. One createTask call must write to Postgres and MongoDB and Redis, and it must not leave data half-written if something fails midway.
Here's the write flow. The relational transaction is the anchor: we only commit it after MongoDB succeeds, so the two authoritative stores agree.
// src/services/taskService.js
import { v4 as uuid } from 'uuid';
import { Task } from '../models/task.js';
import { pgPool, redis } from '../config/db.js';
export async function createTask(data, userId) {
const client = await pgPool.connect(); // dedicated connection for the transaction
const taskId = uuid();
let mongoSaved = false;
try {
await client.query('BEGIN');
// 1. Relational reference row
await client.query(
`INSERT INTO tasks (task_id, title, team_id, assignee_id, created_by)
VALUES ($1, $2, $3, $4, $5)`,
[taskId, data.title, data.teamId, data.assigneeId ?? null, userId]
);
// 2. Rich document in MongoDB (same shared UUID)
await Task.create({
taskId, title: data.title, description: data.description,
priority: data.priority, assigneeId: data.assigneeId,
creatorId: userId, teamId: data.teamId, dueDate: data.dueDate,
tags: data.tags ?? [], customFields: data.customFields ?? {}
});
mongoSaved = true;
// 3. Audit trail (still inside the SQL transaction)
await client.query(
`INSERT INTO audit_logs (entity_type, entity_id, action, actor_id, changes)
VALUES ('task', $1, 'create', $2, $3)`,
[taskId, userId, { title: data.title, teamId: data.teamId }]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK'); // undo Postgres
if (mongoSaved) await Task.deleteOne({ taskId }); // compensate MongoDB
throw err;
} finally {
client.release();
}
// 4. Best-effort side effects (safe to retry, never block the response)
await redis.set(`task:${taskId}`,
JSON.stringify({ taskId, title: data.title, status: 'todo' }),
{ EX: 300 });
if (data.assigneeId) {
await redis.lPush('notifications:queue',
JSON.stringify({ userId: data.assigneeId, type: 'task_assigned', taskId }));
await redis.incr(`user:notif:count:${data.assigneeId}`);
}
return { taskId, ...data };
}
β οΈ The consistency reality
There is no true distributed transaction spanning MongoDB, Postgres, and Redis here β those don't exist across engines out of the box. Instead we use a pragmatic pattern: a SQL transaction as the anchor, a compensating delete if MongoDB fails after the row is written, and best-effort, idempotent side effects in Redis (a lost cache entry just means a cache miss, not corruption). Naming this trade-off explicitly in your README is part of doing the project well.
Reading with cache-aside
export async function getTask(taskId) {
const cached = await redis.get(`task:${taskId}`);
if (cached) return JSON.parse(cached); // cache hit
const task = await Task.findOne({ taskId }).lean(); // miss β source of truth
if (!task) throw new Error('Task not found');
await redis.set(`task:${taskId}`, JSON.stringify(task), { EX: 300 }); // repopulate
return task;
}
β Milestone 3 done whenβ¦
A POST /tasks creates a matching row in Postgres, a document in MongoDB, an audit-log entry, and a cache key in Redis. Deliberately break the Mongo write (e.g. a validation error) and confirm the Postgres row is rolled back β no orphaned reference row survives.
Milestone 4 β Cache, Sessions & Polish
Goal: round out the app with Redis-backed sessions, cache invalidation on writes, and the auth middleware that ties it together.
Sessions in Redis
On login, verify credentials against Postgres, then store a session in Redis keyed by a random id. The session id (not a raw JWT) is what the client sends back.
// src/services/authService.js (excerpt)
import bcrypt from 'bcrypt';
import { v4 as uuid } from 'uuid';
import { pgPool, redis } from '../config/db.js';
export async function login(email, password) {
const { rows } = await pgPool.query(
'SELECT user_id, password_hash FROM users WHERE email = $1', [email]);
const user = rows[0];
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
throw new Error('Invalid credentials');
}
const sessionId = uuid();
await redis.set(`session:${sessionId}`,
JSON.stringify({ userId: user.user_id, email }),
{ EX: 3600 }); // 1-hour TTL = automatic logout
return { sessionId };
}
// src/middleware/auth.js
import { redis } from '../config/db.js';
export async function requireAuth(req, res, next) {
const sessionId = req.headers['x-session-id'];
if (!sessionId) return res.status(401).json({ error: 'Auth required' });
const raw = await redis.get(`session:${sessionId}`);
if (!raw) return res.status(401).json({ error: 'Invalid or expired session' });
req.user = JSON.parse(raw);
next();
}
Invalidate on write
Whenever a task changes, delete its cache key so the next read repopulates from the source of truth. Stale caches are the classic multi-store bug β invalidate eagerly.
export async function updateTask(taskId, patch) {
const updated = await Task.findOneAndUpdate({ taskId }, { $set: patch }, { new: true }).lean();
await redis.del(`task:${taskId}`); // β the crucial line
return updated;
}
β Milestone 4 done whenβ¦
You can register, log in, receive a session id, and use it to create and update tasks. Updating a task clears its cache; the following read shows fresh data. Let a session TTL expire and confirm the next request is rejected.
Build Checklist
Track your weekend against this. Tick each item as you finish; the milestones above map directly to these groups.
π Setup
- β
docker compose upstarts Mongo, Postgres, and Redis - β
.envholds all three connection URIs (and is git-ignored) - β Server prints three green connection checkmarks on boot
- β
/healthreturns200
π Data layer
- β Mongoose task model with a shared
taskIdUUID - β SQL schema with foreign keys and an
audit_logstable - β Documented Redis key conventions
π Core flow
- β
createTaskwrites to all three stores - β Failure mid-write rolls back / compensates β no orphans
- β Audit log records every create and update
- β Cache-aside read (hit, miss, repopulate)
π Auth & polish
- β Passwords hashed with bcrypt
- β Redis-backed sessions with a TTL
- β Auth middleware protects task routes
- β Cache invalidated on every write
- β README explains the source-of-truth and consistency trade-offs
What Good Looks Like
Anyone can make three databases connect. A strong submission shows judgment. Use this rubric to grade yourself honestly.
| Dimension | Needs work | Good | Excellent |
|---|---|---|---|
| Store selection | Data placed arbitrarily | Each store used for a sensible job | Every placement justified in the README against the store's strengths |
| Consistency | Orphaned rows after failures | Rollback + compensating delete works | Trade-offs named explicitly; side effects are idempotent |
| Caching | No cache, or never invalidated | Cache-aside with TTLs | Eager invalidation on write; documented TTL rationale |
| Security | Plaintext passwords | bcrypt + session auth | Helmet, input validation, no secrets in code |
| Code structure | DB calls scattered in routes | Service layer separates data access | Thin controllers, testable services, clear config module |
π The one insight to walk away with
The mark of maturity isn't using three databases β it's being able to explain, per piece of data, why it lives where it lives, and what you gave up to put it there. If your README does that convincingly, you've nailed the project.
Stretch Goals
Finished early, or coming back to level up? Pick one β each deepens a different skill.
- Search across stores: add a full-text task search using MongoDB's
$textindex, filtered by team membership pulled from Postgres. - Real-time notifications: replace the polled notification list with Redis Pub/Sub pushing over WebSockets.
- Analytics dashboard: write a Postgres aggregate query (tasks per status per team) and cache the result in Redis with a 1-minute TTL.
- Simplify on purpose: re-implement TaskForge with only PostgreSQL (using a
jsonbcolumn for flexible fields). Compare the line count and note what you lost and gained β this is the most instructive stretch of all.
Quick Quiz
π― Check Your Understanding
Question 1: In TaskForge, which store is the source of truth for team memberships and permissions, and why?
Question 2: The createTask flow uses a SQL transaction as its anchor and compensates MongoDB on failure. What does this pattern buy you?
Question 3: Why must updateTask call redis.del('task:{id}')?
Summary & What's Next
π Key Takeaways
- Polyglot persistence means picking a store per workload β MongoDB for flexible documents, a relational DB for integrity, Redis for speed and ephemerality.
- Assign each entity a source of truth; other stores hold copies or caches, never the authority.
- Cross-engine writes have no free distributed transaction β anchor on a SQL transaction, compensate on failure, and keep side effects idempotent.
- Cache-aside + eager invalidation keeps reads fast without serving stale data.
- The real skill is judgment: being able to justify β and question β every store choice.
π Further Reading
- MongoDB Manual
- PostgreSQL Documentation
- Redis Documentation
- Martin Fowler β Polyglot Persistence
- Designing Data-Intensive Applications β Martin Kleppmann
π What's Next?
You've now built a real multi-store backend and felt where the databases end and your code begins. In the next module we zoom back out to the framework that glued it all together: how Express actually structures a backend, from routing to middleware to error handling.
π Weekend well spent!
TaskForge is yours. Push it to GitHub, write that honest README, and carry the judgment forward.