Implementing Maker Checker Pattern in Your Application
Technology

Implementing Maker Checker Pattern in Your Application

Financial systems run on trust... breakable trust. One wrong keystroke deletes customer accounts. Single-click typo transfers $800,000 instead of $8,000. Happens more than banks admit.

Addison Aura
Addison Aura
14 min read

Financial systems run on trust... breakable trust. One wrong keystroke deletes customer accounts. Single-click typo transfers $800,000 instead of $8,000. Happens more than banks admit.

Maker checker principle puts brakes on that chaos - minimum two people eyeball critical transactions before execution. First person (maker) creates request. Second person (checker) approves or rejects. Simple concept. Messy implementation.

Why 63% of Organizations Face Check Fraud Without Dual Controls

Association for Financial Professionals found 63% of companies experienced check fraud attempts in 2024. That number jumped from 47% in 2023. Know what organizations had in common? Single-person transaction authority.

PwC discovered 51% of surveyed organizations faced fraud in recent years. Cost? More than reputation damage - actual money vanished. But here's the twist nobody talks about: maker checker creates new problems while solving old ones.

Seen marketing teams wait 3 days for campaign approval because checker went on vacation. Finance departments missed payment deadlines. Compliance theater versus actual security? Thin line.

The pattern works when implemented correctly. Fails when treating it like checkbox exercise for auditors.

Database Architectures That Actually Work (Two Proven Models)

Most tutorials show you theoretical database designs. Here's what production systems use after years of iteration.

Dual Table Approach - banks and financial institutions default to this:

Main table holds approved data. Pending table mirrors structure but adds approval metadata. Changes sit in limbo until authorized.

CREATE TABLE transactions (
    id BIGSERIAL PRIMARY KEY,
    transaction_type VARCHAR(50) NOT NULL,
    amount DECIMAL(15,2) NOT NULL,
    from_account VARCHAR(50) NOT NULL,
    to_account VARCHAR(50) NOT NULL,
    status VARCHAR(20) DEFAULT 'APPROVED',
    executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    executed_by BIGINT NOT NULL
);

CREATE TABLE transactions_pending (
    request_id BIGSERIAL PRIMARY KEY,
    parent_transaction_id BIGINT,
    action_type VARCHAR(20) NOT NULL, -- CREATE, UPDATE, DELETE
    transaction_type VARCHAR(50),
    amount DECIMAL(15,2),
    from_account VARCHAR(50),
    to_account VARCHAR(50),
    maker_id BIGINT NOT NULL,
    checker_id BIGINT,
    status VARCHAR(20) DEFAULT 'PENDING',
    maker_notes TEXT,
    checker_notes TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    reviewed_at TIMESTAMP,
    rejection_reason TEXT,
    CONSTRAINT fk_parent FOREIGN KEY (parent_transaction_id) 
        REFERENCES transactions(id) ON DELETE SET NULL
);

Advantages: Clean data separation. Easy rollback. Automatic audit trail. Main table never touches dirty data.

Drawbacks: Double storage cost. Schema changes need updating both tables. Complex JOIN queries for reporting.

Single Table Status Pattern - lighter-weight teams choose this:

CREATE TABLE payment_requests (
    id BIGSERIAL PRIMARY KEY,
    payment_type VARCHAR(50) NOT NULL,
    amount DECIMAL(15,2) NOT NULL,
    recipient VARCHAR(100) NOT NULL,
    status VARCHAR(20) DEFAULT 'DRAFT',
    maker_id BIGINT NOT NULL,
    checker_id BIGINT,
    submitted_at TIMESTAMP,
    reviewed_at TIMESTAMP,
    rejection_notes TEXT,
    version INT DEFAULT 1,
    is_active BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_status_pending ON payment_requests(status) 
    WHERE status = 'PENDING';

Works for lower-risk operations where history matters less than speed. Smaller storage footprint. Simpler queries.

But... loses modification history. Needs separate audit table anyway for compliance. Vulnerable to direct database edits.

Real-world observation from mobile app development houston projects: hybrid approach wins. Financial transactions get dual tables. User profile updates get status fields. Match pattern to actual risk.

State Machine Rules Nobody Follows (Until Production Breaks)

Request moves through lifecycle states. Jumping states incorrectly? Broken audit trail or worse - compliance violations.

Valid States:

  • DRAFT: Maker still editing
  • PENDING: Awaiting checker review
  • APPROVED: Checker authorized
  • REJECTED: Checker declined
  • CANCELLED: Maker withdrew
  • EXECUTED: Successfully processed
  • FAILED: Execution attempted but errored

Only These Transitions Work:

DRAFT → PENDING (maker submits)
DRAFT → CANCELLED (maker abandons before submit)
PENDING → APPROVED (checker accepts)
PENDING → REJECTED (checker declines)
PENDING → CANCELLED (maker withdraws during review)
APPROVED → EXECUTED (system processes successfully)
APPROVED → FAILED (system processing errors out)
REJECTED → DRAFT (maker revises and resubmits)

Any other transition? Log it. Alert on it. Block it. Either system bug or security breach happening.

State validation belongs in database triggers AND application logic. Defense in depth approach.

CREATE OR REPLACE FUNCTION validate_state_transition()
RETURNS TRIGGER AS $$
BEGIN
    IF OLD.status = 'EXECUTED' THEN
        RAISE EXCEPTION 'Cannot modify executed transaction';
    END IF;
    
    IF NEW.status = 'APPROVED' AND OLD.status != 'PENDING' THEN
        RAISE EXCEPTION 'Can only approve pending requests';
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER enforce_state_machine
BEFORE UPDATE ON payment_requests
FOR EACH ROW EXECUTE FUNCTION validate_state_transition();

Role Separation Enforcement (The Part Everyone Gets Wrong)

Users need maker OR checker role for specific transaction types. Never both. Sounds obvious. Implementation? Tricky.

CREATE TABLE role_matrix (
    id SERIAL PRIMARY KEY,
    resource_type VARCHAR(100) NOT NULL,
    action VARCHAR(50) NOT NULL,
    maker_roles JSONB NOT NULL,
    checker_roles JSONB NOT NULL,
    requires_approval BOOLEAN DEFAULT TRUE,
    same_user_allowed BOOLEAN DEFAULT FALSE,
    min_approval_count INT DEFAULT 1,
    UNIQUE(resource_type, action)
);

INSERT INTO role_matrix (resource_type, action, maker_roles, checker_roles) 
VALUES 
    ('payment', 'create', '["finance_clerk"]', '["finance_manager"]'),
    ('user', 'delete', '["admin"]', '["super_admin"]'),
    ('campaign', 'launch', '["marketing_manager"]', '["marketing_director"]');

Enforcement happens at application AND database layers. Both. Not either-or.

Application layer catches 99% of violations during normal flow. Database constraints catch remaining 1% - direct SQL access, API bypasses, compromised credentials.

Configuration flexibility matters. Not everything needs approval. Blog post edits? Maker-only workflow. Wire transfers above $10,000? Mandatory dual control. Different risk profiles deserve different rules.

Real Backend Implementation With Error Handling

Theory breaks when hitting actual code. Here's Node.js example with PostgreSQL that survives production:

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function createPaymentRequest(req, res) {
    const { userId, amount, recipient, paymentType } = req.body;
    
    const client = await pool.connect();
    
    try {
        await client.query('BEGIN');
        
        // Verify maker role
        const roleCheck = await client.query(
            `SELECT roles FROM users WHERE id = $1`,
            [userId]
        );
        
        if (!roleCheck.rows[0]?.roles.includes('finance_clerk')) {
            throw new Error('Insufficient maker permissions');
        }
        
        // Create pending request
        const result = await client.query(`
            INSERT INTO payment_requests 
            (maker_id, amount, recipient, payment_type, status, submitted_at)
            VALUES ($1, $2, $3, $4, 'PENDING', NOW())
            RETURNING id, status
        `, [userId, amount, recipient, paymentType]);
        
        // Notify eligible checkers
        await notifyCheckers('payment', result.rows[0].id);
        
        await client.query('COMMIT');
        
        res.json({
            requestId: result.rows[0].id,
            status: result.rows[0].status,
            message: 'Payment request created - awaiting approval'
        });
        
    } catch (error) {
        await client.query('ROLLBACK');
        console.error('Payment request creation failed:', error);
        res.status(500).json({ error: error.message });
    } finally {
        client.release();
    }
}

async function reviewPaymentRequest(req, res) {
    const { requestId, checkerId, decision, notes } = req.body;
    
    const client = await pool.connect();
    
    try {
        await client.query('BEGIN');
        
        // Verify checker role and request status
        const validation = await client.query(`
            SELECT pr.*, u.roles 
            FROM payment_requests pr
            CROSS JOIN users u
            WHERE pr.id = $1 AND u.id = $2
        `, [requestId, checkerId]);
        
        if (!validation.rows[0]) {
            throw new Error('Request or user not found');
        }
        
        const request = validation.rows[0];
        
        if (!request.roles.includes('finance_manager')) {
            throw new Error('Insufficient checker permissions');
        }
        
        if (request.status !== 'PENDING') {
            throw new Error(`Cannot review ${request.status} request`);
        }
        
        if (request.maker_id === checkerId) {
            throw new Error('Same user cannot be maker and checker');
        }
        
        // Update request
        const newStatus = decision === 'approve' ? 'APPROVED' : 'REJECTED';
        
        await client.query(`
            UPDATE payment_requests 
            SET status = $1, checker_id = $2, checker_notes = $3, reviewed_at = NOW()
            WHERE id = $4
        `, [newStatus, checkerId, notes, requestId]);
        
        // If approved, execute payment
        if (newStatus === 'APPROVED') {
            await executePayment(client, requestId);
        }
        
        await client.query('COMMIT');
        
        res.json({
            requestId,
            status: newStatus,
            message: `Payment request ${decision}d successfully`
        });
        
    } catch (error) {
        await client.query('ROLLBACK');
        console.error('Payment review failed:', error);
        res.status(500).json({ error: error.message });
    } finally {
        client.release();
    }
}

Production systems add retry logic, webhook notifications, audit logging, metric tracking. Starting point matters though.

Automation Bottleneck Solutions (2025 Workflow Data)

80% of organizations plan increasing automation investment by 2025 according to Gartner surveys. Maker checker workflows? Prime automation candidates.

Auto-escalation when checkers ghost requests:

// Cron job runs every hour
async function escalateStaleRequests() {
    const staleThreshold = 24; // hours
    
    const staleRequests = await pool.query(`
        SELECT id, maker_id, created_at 
        FROM payment_requests
        WHERE status = 'PENDING' 
        AND created_at < NOW() - INTERVAL '${staleThreshold} hours'
    `);
    
    for (const request of staleRequests.rows) {
        // Notify backup checkers
        await notifyBackupCheckers(request.id);
        
        // Log escalation
        await auditLog({
            action: 'ESCALATED',
            requestId: request.id,
            reason: `No action taken within ${staleThreshold} hours`
        });
    }
}

Smart checker assignment based on workload:

Instead of notifying all checkers, route to least-busy qualified person. Reduces decision fatigue. Speeds approvals.

Conditional approval thresholds:

Payments under $1,000? Single checker. $1,000 - $10,000? One manager approval. Above $10,000? Two manager approvals required.

Risk-based automation beats blanket policies.

What Success Looks Like (Metrics That Matter)

Workflow automation reduces repetitive tasks by 60-95% according to 2025 benchmarks. But maker checker success needs specific metrics:

Approval Cycle Time: Target under 4 hours for standard requests. Exceeding 24 hours? Bottleneck exists.

Rejection Rate: 5-10% healthy range. Higher? Maker training needed. Lower? Checkers rubber-stamping without real review.

Same-Day Resolution Rate: Aim for 85%+ of requests resolved within business day submitted.

Fraud Detection Rate: Track how many fraudulent or erroneous requests checkers catch. Zero catches? Process not working.

Monitor these monthly. Adjust thresholds and automation rules based on actual patterns.

The goal? Security without bureaucracy. Compliance without paralysis. Two-person control that enhances operations rather than suffocating them.

That balance takes iteration. Start with strict rules. Loosen based on data. Never the reverse.

Discussion (0 comments)

0 comments

No comments yet. Be the first!