Skip to main content

πŸ› οΈ 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
TaskForge polyglot-persistence architecture The Express API sits between the client and three data stores: MongoDB for documents, a relational database for structured integrity, and Redis for caching and sessions. Client browser / app Express API services layer MongoDB task detail Β· profiles Β· comments PostgreSQL / MySQL teams Β· permissions Β· audit Redis sessions Β· cache Β· queue
Figure 1 β€” The API is the only component that talks to all three stores. Keeping data access behind a service layer is what makes juggling three databases manageable.

πŸ’‘ 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 patternTypePurposeTTL
session:{sessionId}string (JSON)Logged-in session data1 hour
task:{taskId}string (JSON)Cached task summary5 min
notifications:queuelistPending notificationsnone
user:notif:count:{userId}integerUnread badge counternone

βœ… 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.

sequenceDiagram participant C as Client participant A as API participant P as PostgreSQL participant M as MongoDB participant R as Redis C->>A: POST /tasks A->>P: BEGIN + INSERT task row A->>M: save() task document M-->>A: ok A->>P: INSERT audit log A->>P: COMMIT A->>R: cache task (TTL 5m) A->>R: LPUSH notification A-->>C: 201 Created
// 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 up starts Mongo, Postgres, and Redis
  • ☐ .env holds all three connection URIs (and is git-ignored)
  • ☐ Server prints three green connection checkmarks on boot
  • ☐ /health returns 200

πŸ“‹ Data layer

  • ☐ Mongoose task model with a shared taskId UUID
  • ☐ SQL schema with foreign keys and an audit_logs table
  • ☐ Documented Redis key conventions

πŸ“‹ Core flow

  • ☐ createTask writes 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.

DimensionNeeds workGoodExcellent
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 $text index, 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 jsonb column 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

πŸš€ 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.