Skip to content

The Anatomy of a Technical Spec

October 16, 2026

The Anatomy of a Technical Spec

The best way to learn how architects think is to watch one work. Not a whiteboard session, not a slide deck — a real spec, with the reasoning exposed.

What follows is a complete technical spec for a notification delivery service. It's based on a real system I designed, simplified enough to fit in a blog post but detailed enough to show how each section earns its place. After each section, I'll break down why it's there, what decisions are baked in, and where I'm making deliberate tradeoffs.

If you've read my post on spec-driven development, this is what a finished spec actually looks like.


The Spec

Section 1: Problem Statement

Context: Our platform sends transactional notifications (order confirmations, password resets, shipping updates) through a monolithic API server. Notifications are sent synchronously during the request lifecycle — the user waits while we call SendGrid.

Problem: During peak hours, SendGrid latency spikes to 3-5 seconds, which blocks the API response. Users see slow checkouts. Last Black Friday, the email provider had a 12-minute outage that caused 2,400 failed checkouts because the order endpoint couldn't complete without a successful email send.

Why now: Black Friday is 6 weeks out. Marketing wants to add SMS and push notifications for the holiday campaign. Bolting more channels onto the synchronous path would make the problem worse.

Why this section matters:

Three paragraphs. That's it. Context tells you what exists. Problem tells you what's broken, in business terms with numbers. Why now tells you what's forcing the decision.

The temptation is to write a page of background. Resist it. If someone needs more history, they can ask. The problem statement's job is to make a reader care in thirty seconds.

Notice I didn't mention Kafka, queues, or microservices here. The problem statement describes pain, not technology. The moment you put a solution in the problem statement, you've anchored the conversation and closed off alternatives you haven't considered yet.

The numbers matter — "2,400 failed checkouts" and "3-5 seconds" are what get this spec prioritized. "Our notification system is suboptimal" gets filed and forgotten.


Section 2: Constraints

Hard constraints:

  • Must be production-ready before Black Friday (6-week window)
  • Email delivery SLA: 99.5% of transactional emails delivered within 60 seconds
  • Cannot modify the checkout API contract — mobile apps depend on the current response shape
  • Budget: no new infrastructure spend beyond existing AWS account

Soft constraints:

  • Team prefers TypeScript (current stack)
  • Should use existing AWS services where possible (SQS, SNS, Lambda)
  • Prefer minimal new services to operate — team of four, no dedicated SRE

Inherited constraints:

  • Existing API is Express.js on ECS Fargate
  • PostgreSQL 15 on RDS (db.r6g.xlarge)
  • CI/CD through GitHub Actions, deploys take ~8 minutes
  • No existing message queue infrastructure
  • SendGrid for email (contract locked through Q2 next year)

Why this section matters:

Constraints are the walls your solution has to fit inside. I separate them into three categories because they have different weights in decision-making.

Hard constraints aren't negotiable. The 6-week deadline isn't a preference — it's a business reality. If your architecture can't ship in that window, it's the wrong architecture, no matter how elegant.

Soft constraints are defaults you'll follow unless there's a strong reason not to. "Team prefers TypeScript" means I won't propose a Go service without justifying why TypeScript won't work.

Inherited constraints are the ones that kill architectures designed on a whiteboard. I've seen specs that proposed Kubernetes deployments for teams that deploy with rsync. Those specs don't fail technically — they fail organizationally. "No existing message queue infrastructure" means I can't assume the team knows how to operate Kafka. That shapes the entire design.


Section 3: Proposed Solution

Decouple notification delivery from the API request lifecycle using an async queue.

┌──────────┐     ┌───────────┐     ┌──────────────────┐
│ Checkout │────▶│  SQS      │────▶│ Notification     │
│ API      │     │  Queue    │     │ Worker (Lambda)  │
└──────────┘     └───────────┘     └──────┬───────────┘
                                          │
                             ┌────────────┼────────────┐
                             │            │            │
                        ┌────▼───┐  ┌─────▼──┐  ┌─────▼──┐
                        │SendGrid│  │  SNS   │  │  FCM   │
                        │(email) │  │ (SMS)  │  │ (push) │
                        └────────┘  └────────┘  └────────┘

Flow:

  1. Checkout API writes the order to the database and enqueues a notification message to SQS. API responds immediately — no waiting on email delivery.
  2. Lambda worker picks up the message, resolves the user's notification preferences, and dispatches to the appropriate channel(s).
  3. Delivery status is written back to a notification_log table for tracking and retry.

Retry strategy: Failed deliveries retry 3 times with exponential backoff (30s, 2m, 10m). After 3 failures, the notification is moved to a dead-letter queue for manual review.

Why this section matters:

One diagram. One flow description. That's the test for whether your solution is simple enough for v1.

This is deliberately boring architecture. SQS and Lambda are managed services — no clusters to operate, no brokers to monitor. A team of four can ship this in weeks, not months. I'm not reaching for Kafka because Kafka is better technology. I'm reaching for SQS because it's better for this team, this timeline, and this problem.

The diagram earns its place only if it clarifies something that prose can't. If your diagram needs a legend with more than five symbols, the architecture is too complex for v1.

Notice what's missing: there's no CQRS, no event sourcing, no saga orchestration. Those patterns exist for good reasons, but this system doesn't need them yet. Every pattern you add is a pattern someone has to understand, debug, and maintain. I'd rather ship the simple version and discover which patterns we actually need from production data.


Section 4: Key Decisions

DecisionChoiceRationale
Queue technologySQSManaged, no infra to operate, team has AWS experience, sufficient throughput (3,000 msg/s standard)
Worker runtimeLambdaEvent-driven, scales to zero, no servers to patch, SQS trigger built-in
New channelsSNS for SMS, FCM for pushAWS-native SMS, FCM is industry standard for mobile push
Notification preferencesDatabase column on users tableSimple boolean flags per channel, no need for a preferences service yet
Template renderingIn the Lambda workerKeeps templates close to delivery logic, can extract later if templates get complex

Why this section matters:

Each row is a decision someone will question in six months. "Why didn't we use Kafka?" "Why Lambda instead of an ECS service?" The rationale column is your future self's best friend.

I keep rationales to one sentence. If it takes a paragraph to justify a decision, either the decision is wrong or you're over-explaining a reasonable choice.

The decisions table is also where I document what I chose not to build. "No need for a preferences service yet" is a decision. It says: I considered it, I decided against it, here's why. That's different from never having thought about it.


Section 5: Tradeoffs

Accepting:

  • At-least-once delivery: SQS guarantees at-least-once, so a user might receive a duplicate notification. For transactional emails, this is acceptable — a duplicate "order confirmed" email is a minor annoyance, not a business problem. If we need exactly-once later, we add idempotency keys.
  • No real-time delivery tracking in v1: The notification_log table gives us after-the-fact visibility but not a live dashboard. We'll add that in Phase 2 if support teams need it.
  • SendGrid lock-in through Q2: We're wrapping SendGrid behind an interface so we can swap providers later, but we're not building a multi-provider abstraction now.

Not accepting:

  • Data loss: SQS messages are durable. DLQ catches failures. Every notification attempt is logged. We do not silently drop messages.
  • Checkout degradation: If SQS is unreachable, the checkout still completes — we log the notification failure and a background job retries from the database. The user's order is never at risk.
  • No observability: CloudWatch alarms on queue depth, Lambda errors, and delivery failure rate from day one.

Why this section matters:

This is where the architect earns their keep.

Every system involves tradeoffs. The spec is where you make them visible and deliberate. "Accepting at-least-once delivery" is a conscious choice with a fallback plan (idempotency keys later). That's different from discovering duplicate notifications in production and scrambling to fix them.

The "not accepting" list is just as important. It tells the team where corners will not be cut. Data loss is off the table. Checkout reliability is off the table. Observability is off the table. These are the load-bearing walls of the system — everything else can flex.

I've found that teams ship faster when tradeoffs are explicit. Without them, every code review becomes a debate about whether something is "good enough." With them, the spec already settled that question.


Section 6: Data Model

CREATE TABLE notification_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id),
    type            VARCHAR(50) NOT NULL,  -- 'order_confirmed', 'password_reset', etc.
    channel         VARCHAR(20) NOT NULL,  -- 'email', 'sms', 'push'
    status          VARCHAR(20) NOT NULL DEFAULT 'queued',
                    -- 'queued', 'sent', 'delivered', 'failed', 'dlq'
    provider_ref    VARCHAR(255),          -- SendGrid message ID, SNS message ID
    payload         JSONB NOT NULL,        -- template data (order ID, amounts, etc.)
    attempts        INT NOT NULL DEFAULT 0,
    last_attempt_at TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_notification_user ON notification_log(user_id, created_at DESC);
CREATE INDEX idx_notification_status ON notification_log(status)
    WHERE status IN ('queued', 'failed');

User preferences (added to existing users table):

ALTER TABLE users ADD COLUMN notify_email BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE users ADD COLUMN notify_sms BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN notify_push BOOLEAN NOT NULL DEFAULT false;

Why this section matters:

I will delay a project to get the data model right. A wrong data model is the most expensive mistake in software because it poisons everything built on top of it.

The partial index on status WHERE status IN ('queued', 'failed') is a small thing that matters. Most rows will be 'delivered' — we only need fast lookups on the active ones. This is the kind of detail that separates a spec from a wishlist.

Notification preferences as boolean columns on the users table is a deliberate simplification. A preferences microservice would be "more correct." It would also take two weeks to build, need its own database, and add a network hop to every notification send. Three boolean columns ship in an hour and handle every use case we have today.

When the product team asks for per-notification-type preferences or quiet hours, that's when we extract a preferences service. Not before.


Section 7: API Contract

Internal API (called by checkout service):

POST /internal/notifications/enqueue
{
  "user_id": "uuid",
  "type": "order_confirmed",
  "payload": {
    "order_id": "ORD-12345",
    "total": 149.99,
    "items": [...]
  }
}

Response: 202 Accepted
{ "notification_id": "uuid" }

No external API in v1. Support teams use direct database queries. A notification status API comes in Phase 2 if they need it.

Why this section matters:

The API contract is what other teams build against. Changing it later is expensive — every consumer has to update.

Returning 202 Accepted instead of 200 OK signals that the work happens asynchronously. It's a small detail that sets correct expectations for every developer who reads this endpoint.

"No external API in v1" is a scope cut. It's tempting to build the status-check endpoint while you're in there. Don't. Ship the core path. See if anyone actually asks for the status endpoint. Half the time, they won't.


Section 8: Failure Modes

FailureImpactMitigation
SQS unreachableNotifications delayedCheckout writes to notification_log with status 'queued'; background cron retries every 60s
Lambda timeoutNotification not sentSQS visibility timeout requeues automatically; message retries up to 3x
SendGrid outageEmails not deliveredRetry with backoff; after 3 failures, move to DLQ; alert on-call
Database downNothing worksExisting RDS failover; this system has the same blast radius as every other service
Malformed messageWorker crashesValidate message schema on receipt; reject invalid messages to DLQ with logged reason

Why this section matters:

Production systems don't fail cleanly — they fail in specific, predictable ways. Listing those failures in the spec means you design for them before they happen, not during an incident at 2 AM.

The key question for each failure: what does the user experience? If SQS is down, the user's checkout still works — they just get their confirmation email late. That's acceptable. If the database is down, everything is broken — but that's a pre-existing risk, not something this project introduces.

The mitigations aren't heroic. They're boring: retry, backoff, dead-letter queue, alert. Boring is good. Boring means someone can debug it at 2 AM without understanding the full system.


Section 9: Ideal State and Roadmap

Phase 1 — Walking skeleton (Weeks 1-4): Ship the async queue path for email only. Checkout enqueues to SQS, Lambda delivers via SendGrid. Monitoring and DLQ in place. This solves the Black Friday risk.

Phase 2 — Multi-channel (Weeks 5-8): Add SMS (SNS) and push (FCM) channels. Build notification preferences UI. Add a simple delivery status API for the support team.

Phase 3 — Harden (Month 3-4): Add delivery rate monitoring dashboard. Implement provider failover (secondary email provider). Add per-notification-type preferences and quiet hours.

Phase 4 — Evolve (Month 6+): Extract notification service into its own deployment. Evaluate EventBridge for cross-service notification triggers. Consider notification batching (daily digest).

Ideal state:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ Any Service  │────▶│ EventBridge  │────▶│ Notification │
│ (checkout,   │     │ (event bus)  │     │ Service      │
│  billing,    │     └──────────────┘     │ (standalone) │
│  support)    │                          └──────┬───────┘
└──────────────┘                                 │
                                    ┌────────────┼────────────┐
                                    │            │            │
                               ┌────▼───┐  ┌────▼───┐  ┌────▼───┐
                               │Provider│  │Provider│  │Provider│
                               │Router  │  │Router  │  │Router  │
                               │(email) │  │ (SMS)  │  │(push)  │
                               └────────┘  └────────┘  └────────┘

Why this section matters:

Every spec has two architectures: what we're building now and where we want to end up. The gap between them is the roadmap.

Phase 1 is the only phase with a firm commitment. It solves the stated problem within the stated constraints. Everything after Phase 1 is a plan, not a promise.

The phases are sequenced so each one is independently valuable. If Phase 3 never happens because Phase 2 handles the load fine, that's a win. The roadmap gives direction without creating obligation.

The ideal state diagram exists so the team knows where they're heading. It prevents well-intentioned decisions in Phase 1 that would make Phase 4 impossible. You don't build toward the ideal state — you build so you can build toward it when the time comes.


Section 10: Open Questions

  1. SMS opt-in compliance: Do we need double opt-in for SMS under TCPA? Legal review needed before Phase 2. Owner: Legal team.
  2. SendGrid rate limits: Current plan allows 100 emails/second. Black Friday estimate is 80/s peak. Is that enough margin? Owner: Platform team to verify.
  3. Push notification certificates: Do we have valid APNs certs for iOS? Last renewal was 8 months ago. Owner: Mobile team.
  4. Template ownership: Who maintains notification templates — product or engineering? Needs a decision before we build the template system. Owner: Product lead.

Why this section matters:

This is often the most valuable section of the entire spec. It surfaces the things you don't know, names who needs to answer them, and makes the unknowns visible before they become blockers.

Open questions are not weaknesses — they're proof that you've thought hard enough to find the edges of your knowledge. A spec with no open questions either describes a trivial system or was written by someone who didn't look hard enough.

Every question has an owner. An unowned question is a question nobody will answer.


The spec is a thinking tool

The sections above aren't a template to fill in mechanically. They're a structured way to force yourself through the hard decisions before they become expensive.

The order matters. Problem before solution — so you don't anchor on technology before understanding the pain. Constraints before design — so you don't sketch a fantasy architecture. Tradeoffs before implementation — so the team knows where corners are being cut and where they aren't.

The best specs I've written look nothing like their first drafts. That's the point. The first draft is a conversation starter. The questions it provokes — "What happens if SendGrid is down for an hour?" "Can we really ship this in six weeks?" — are exactly the questions you want answered at spec time rather than during an incident.

A spec isn't a deliverable. It's a thinking tool that happens to leave a useful artifact behind.

—
Share this article