Full-Stack Data Flow — API Contract, Response Envelopes, and State Machine

A MEAN Stack application is only as good as the connection between its two halves. The Angular frontend and the Express API must speak the same language: consistent response envelopes, predictable error codes, typed interfaces shared across both ends, and a data-fetching architecture that handles loading, error, and empty states elegantly. This lesson establishes the full-stack data flow conventions that make the rest of the integration patterns in this chapter possible — from the API response contract to the Angular service layer that consumes it.

Full-Stack Data Contract

Concern Express (Server) Angular (Client)
Success response shape { success: true, data: T, meta?: Pagination } ApiResponse<T> interface — map to data in ApiService
Error response shape { message: string, code?: string, errors?: FieldError[] } Read err.error.message in catchError handler
HTTP status codes 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 429 Too Many Requests, 500 Server Error Switch on err.status in error interceptor
Pagination headers X-Total-Count: 47 response header observe: 'response' to read headers
TypeScript models Mongoose schema shape informs interface Shared task.model.ts interfaces

UI State Machine

State Signal Values Template Shows
Idle loading=false, data=[], error=null Empty prompt or initial placeholder
Loading loading=true, data=[], error=null Skeleton loaders or spinner
Loaded loading=false, data=[...], error=null Data list or detail view
Empty loading=false, data=[], error=null Empty state illustration + CTA
Error loading=false, data=[], error='msg' Error message + retry button
Note: Consistent API response envelopes are the single highest-leverage convention in full-stack development. When every endpoint returns { success: true, data: T } for success and { message: string } for errors, the Angular ApiService can map all responses uniformly with one map(r => r.data) operator. Without this convention, every service method must handle a different response shape — multiplying the places where bugs can hide.
Tip: Define TypeScript interfaces in a shared location and use them on both the Express route handler return type annotations and the Angular service generic parameters. Even though Node.js does not enforce types at runtime, TypeScript annotations in Express route handlers document the contract and catch mismatches during development. Use a types/ folder at the monorepo root — or duplicate a minimal set in shared/models/ on the Angular side — as the single source of truth.
Warning: Never return different shapes from the same endpoint for success vs validation error. Some APIs return { data: [...] } on success but { errors: [...] } (no data) on validation failure — forcing the client to check which shape it received before accessing anything. Standardise: success always has data, errors always have message, optionally with an errors array for field-level validation. The HTTP status code (400 vs 200) tells the client which shape to expect.

Complete Full-Stack Data Flow

// ── Express: consistent response helper ──────────────────────────────────
// utils/response.js
const success = (res, data, statusCode = 200, meta = undefined) =>
    res.status(statusCode).json({ success: true, data, ...(meta ? { meta } : {}) });

const created = (res, data) => success(res, data, 201);

const paginated = (res, data, total, page, limit) => {
    const totalPages = Math.ceil(total / limit);
    res.setHeader('X-Total-Count', total);
    return success(res, data, 200, {
        total, page, limit, totalPages,
        hasNextPage: page < totalPages,
        hasPrevPage: page > 1,
    });
};

module.exports = { success, created, paginated };

// ── Express: task controller ──────────────────────────────────────────────
const { success, created, paginated } = require('../utils/response');
const asyncHandler = require('express-async-handler');
const Task = require('../models/task.model');

exports.getAll = asyncHandler(async (req, res) => {
    const page  = Math.max(1, parseInt(req.query.page) || 1);
    const limit = Math.min(50, parseInt(req.query.limit) || 10);
    const skip  = (page - 1) * limit;

    const filter = { user: req.user.sub, deletedAt: { $exists: false } };
    if (req.query.status)   filter.status   = req.query.status;
    if (req.query.priority) filter.priority = req.query.priority;
    if (req.query.q) {
        filter.$text = { $search: req.query.q };
    }

    const [tasks, total] = await Promise.all([
        Task.find(filter).sort('-createdAt').skip(skip).limit(limit).lean(),
        Task.countDocuments(filter),
    ]);

    paginated(res, tasks, total, page, limit);
});

exports.getById = asyncHandler(async (req, res) => {
    const task = await Task.findOne({ _id: req.params.id, user: req.user.sub }).lean();
    if (!task) return res.status(404).json({ message: 'Task not found' });
    success(res, task);
});

exports.create = asyncHandler(async (req, res) => {
    const task = await Task.create({ ...req.body, user: req.user.sub });
    created(res, task);
});

exports.update = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndUpdate(
        { _id: req.params.id, user: req.user.sub },
        req.body,
        { new: true, runValidators: true }
    );
    if (!task) return res.status(404).json({ message: 'Task not found' });
    success(res, task);
});

exports.remove = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndDelete({ _id: req.params.id, user: req.user.sub });
    if (!task) return res.status(404).json({ message: 'Task not found' });
    success(res, null, 204);
});
// ── Angular: shared model interfaces ────────────────────────────────────
// shared/models/task.model.ts
export interface Task {
    _id:          string;
    title:        string;
    description?: string;
    status:       'pending' | 'in-progress' | 'completed';
    priority:     'low' | 'medium' | 'high';
    dueDate?:     string;   // ISO date string
    tags:         string[];
    user:         string;
    createdAt:    string;
    updatedAt:    string;
    isOverdue?:   boolean;  // virtual from server
}

export interface CreateTaskDto {
    title:        string;
    description?: string;
    priority?:    Task['priority'];
    dueDate?:     string;
    tags?:        string[];
}

export type UpdateTaskDto = Partial<CreateTaskDto> & { status?: Task['status'] };

export interface TaskQueryParams {
    page?:     number;
    limit?:    number;
    status?:   Task['status'];
    priority?: Task['priority'];
    q?:        string;
    sort?:     string;
}

// ── Angular: complete task store with state machine ───────────────────────
// core/stores/task.store.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { TaskService }    from '../services/task.service';
import { ToastService }   from '../services/toast.service';
import { Task, CreateTaskDto, UpdateTaskDto, TaskQueryParams } from '../../shared/models/task.model';
import { PaginationMeta } from '../services/api.service';

type LoadState = 'idle' | 'loading' | 'loaded' | 'error';

@Injectable({ providedIn: 'root' })
export class TaskStore {
    private taskService = inject(TaskService);
    private toast       = inject(ToastService);

    // ── Private state signals ─────────────────────────────────────────────
    private _tasks     = signal<Task[]>([]);
    private _loadState = signal<LoadState>('idle');
    private _error     = signal<string | null>(null);
    private _meta      = signal<PaginationMeta | null>(null);
    private _pending   = signal<Set<string>>(new Set());
    private _params    = signal<TaskQueryParams>({ page: 1, limit: 10 });

    // ── Public read-only state ────────────────────────────────────────────
    readonly tasks     = this._tasks.asReadonly();
    readonly loadState = this._loadState.asReadonly();
    readonly error     = this._error.asReadonly();
    readonly meta      = this._meta.asReadonly();
    readonly params    = this._params.asReadonly();

    // ── Derived state ─────────────────────────────────────────────────────
    readonly isLoading = computed(() => this._loadState() === 'loading');
    readonly isLoaded  = computed(() => this._loadState() === 'loaded');
    readonly isEmpty   = computed(() => this.isLoaded() && this._tasks().length === 0);
    readonly hasError  = computed(() => this._loadState() === 'error');
    readonly isPending = (id: string) => computed(() => this._pending().has(id));

    readonly overdueTasks = computed(() =>
        this._tasks().filter(t =>
            t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'completed'
        )
    );

    // ── Actions ───────────────────────────────────────────────────────────
    load(params?: TaskQueryParams): void {
        const merged = { ...this._params(), ...params };
        this._params.set(merged);
        this._loadState.set('loading');
        this._error.set(null);

        this.taskService.getAll(merged).subscribe({
            next: ({ data, meta }) => {
                this._tasks.set(data);
                this._meta.set(meta);
                this._loadState.set('loaded');
            },
            error: err => {
                this._error.set(err.error?.message ?? 'Failed to load tasks');
                this._loadState.set('error');
            },
        });
    }

    create(dto: CreateTaskDto): void {
        this.taskService.create(dto).subscribe({
            next: task => {
                this._tasks.update(tasks => [task, ...tasks]);
                this._meta.update(m => m ? { ...m, total: m.total + 1 } : m);
                this.toast.success('Task created');
            },
            error: err => this.toast.error(err.error?.message ?? 'Failed to create task'),
        });
    }

    update(id: string, dto: UpdateTaskDto): void {
        this._setPending(id, true);
        this.taskService.update(id, dto).subscribe({
            next: updated => {
                this._tasks.update(tasks => tasks.map(t => t._id === id ? updated : t));
                this._setPending(id, false);
                this.toast.success('Task updated');
            },
            error: err => {
                this.toast.error(err.error?.message ?? 'Failed to update task');
                this._setPending(id, false);
            },
        });
    }

    delete(id: string): void {
        this._setPending(id, true);
        this.taskService.delete(id).subscribe({
            next: () => {
                this._tasks.update(tasks => tasks.filter(t => t._id !== id));
                this._meta.update(m => m ? { ...m, total: m.total - 1 } : m);
                this._setPending(id, false);
                this.toast.success('Task deleted');
            },
            error: err => {
                this.toast.error(err.error?.message ?? 'Failed to delete task');
                this._setPending(id, false);
            },
        });
    }

    private _setPending(id: string, v: boolean): void {
        this._pending.update(s => {
            const n = new Set(s); v ? n.add(id) : n.delete(id); return n;
        });
    }
}

How It Works

Step 1 — Response Helpers Enforce the Contract

The success(), created(), and paginated() helper functions in Express ensure every response follows the same shape. Controllers call these helpers instead of calling res.json() directly — making it impossible to accidentally return a different shape. When the contract needs to change (adding a requestId field to every response), updating the helper changes every response at once.

Step 2 — The State Machine Prevents Invalid UI States

The LoadState type ('idle' | 'loading' | 'loaded' | 'error') ensures the UI can only be in one state at a time. Setting loading: true AND error: 'message' simultaneously is not possible — state transitions are controlled through the _loadState signal. The template renders different UI for each state using @if/@else blocks, guided by the computed isLoading, isLoaded, isEmpty, and hasError signals.

Step 3 — Pagination Meta Enables Cursor Navigation

The server sends both the current page’s data and pagination metadata (total count, current page, pages available) in the same response. The client stores this metadata in _meta signal and uses it to render page controls, disable “Next” when on the last page, and show “X of Y results”. The X-Total-Count header approach (also used) allows the Angular ApiService to read it with observe: 'response'.

Step 4 — Per-Item Pending State Enables Granular Loading UX

The _pending Set tracks which task IDs have in-flight requests. The computed isPending(id) factory returns a computed signal for that specific ID. Task cards can show a loading indicator or disable buttons for the one task being processed, while all other tasks remain interactive. This is far better UX than a global loading flag that disables the entire list while one item is being updated.

Step 5 — Errors Are Surfaced to the User, Not Silently Swallowed

Every action handler has an error: callback that calls toast.error() with the API’s error message. API error messages are designed to be user-readable — “Title is required”, “Task not found”, “You have reached your task limit”. The err.error?.message ?? 'fallback' pattern reads the message from the parsed JSON response body with a safe fallback for network errors that have no JSON body.

Common Mistakes

Mistake 1 — Different response shapes for success and error

❌ Wrong — client must check which shape was returned:

// Success:  { tasks: [...] }
// Error:    { errors: ['Title required'] }
// Client:   if (res.tasks) { ... } else if (res.errors) { ... }  // fragile

✅ Correct — consistent envelope, HTTP status distinguishes success from error:

// 200: { success: true, data: [...] }
// 400: { message: 'Validation failed', errors: [{field: 'title', msg: 'required'}] }

Mistake 2 — No empty state — blank screen confuses users

❌ Wrong — component renders nothing when data array is empty:

@if (store.isLoaded()) {
    @for (task of store.tasks(); track task._id) { <app-task-card [task]="task"> }
}  <!-- empty list = blank white screen -->

✅ Correct — explicit empty state with a call to action:

@if (store.isEmpty()) {
    <div class="empty-state">
        <p>No tasks yet.</p>
        <button routerLink="/tasks/new">Create your first task</button>
    </div>
}

Mistake 3 — Loading state not cleared on error

❌ Wrong — spinner runs forever after API error:

this.taskService.getAll().subscribe({
    next: tasks => { this.tasks.set(tasks); this.loading.set(false); },
    error: err  => { this.error.set(err.message); }  // loading never cleared!
});

✅ Correct — clear loading in both next and error:

this.taskService.getAll().subscribe({
    next:  tasks => { this.tasks.set(tasks); this._loadState.set('loaded'); },
    error: err   => { this._error.set(err.message); this._loadState.set('error'); },
});

Quick Reference

Task Pattern
Express success response res.status(200).json({ success: true, data: result })
Express error response res.status(400).json({ message: 'Validation failed' })
Angular read error message err.error?.message ?? 'Unknown error'
Angular read response header http.get(url, { observe: 'response' }).pipe(map(r => r.headers.get('X-Total-Count')))
State machine signal private _state = signal<'idle'|'loading'|'loaded'|'error'>('idle')
Per-item pending private _pending = signal<Set<string>>(new Set())
Empty state check isEmpty = computed(() => isLoaded() && tasks().length === 0)

🧠 Test Yourself

An API returns { tasks: [...] } on success and { error: 'Not found' } on 404. Why is this problematic for the Angular client?