DEV Community

Cover image for The "Minimum Viable System": How to Ship Early Without Shipping Junk
Derek mwale
Derek mwale

Posted on

The "Minimum Viable System": How to Ship Early Without Shipping Junk

There is a dangerous piece of advice in software development:

"Just build an MVP."

It sounds reasonable.

Build the smallest possible version.

Launch quickly.

Get feedback.

Improve later.

The problem is that many developers interpret "minimum viable product" as:

Remove everything.
Skip architecture.
Ignore security.
Ignore testing.
Hardcode everything.
Ship it.
Enter fullscreen mode Exit fullscreen mode

Then six months later, the product is technically alive but practically dead.

Every new feature breaks something.

Every database migration is terrifying.

Every customer request requires a workaround.

The codebase has become a collection of decisions that made sense individually but make no sense together.

The team spends more time fighting the system than improving the product.

This is where I think the idea of a Minimum Viable System becomes much more useful.

The goal isn't to build the smallest amount of software possible.

The goal is to build the smallest system that can safely support learning.

That distinction matters.

A Minimum Viable System is not junk software.

It is intentionally incomplete software with enough engineering discipline to survive contact with reality.

The system doesn't need every feature.

It doesn't need perfect scalability.

It doesn't need a distributed architecture.

It doesn't need Kubernetes.

It doesn't need twelve microservices.

But it does need a few things to be fundamentally correct.

It needs a coherent data model.

It needs clear boundaries.

It needs basic observability.

It needs reasonable security.

It needs a deployment process.

It needs tests around important behavior.

And most importantly, it needs to be designed so that the next version doesn't require throwing away the first one.

That is the difference between shipping early and shipping junk.


The Problem With "Move Fast"

Software engineers often imagine speed as:

Idea
 ↓
Code
 ↓
Deploy
Enter fullscreen mode Exit fullscreen mode

But real software doesn't work that way.

The moment users arrive, the system becomes a living thing.

                USERS
                  │
                  ▼
              PRODUCT
                  │
       ┌──────────┼──────────┐
       ▼          ▼          ▼
    Traffic     Data       Feedback
       │          │          │
       └──────────┼──────────┘
                  ▼
              ENGINEERING
                  │
                  ▼
               CHANGE
                  │
                  ▼
               PRODUCT
Enter fullscreen mode Exit fullscreen mode

Every deployment creates new constraints.

Every customer creates new data.

Every integration creates another dependency.

Every feature creates another behavior.

The software becomes increasingly difficult to change.

So the real objective of early development should not be:

"How quickly can I write the first version?"

It should be:

"How quickly can I create a system that teaches me what I need to know without creating unnecessary future problems?"

That is a much better question.


MVP vs Minimum Viable System

Let's make the distinction explicit.

A traditional MVP often gets described as:

Minimum
+
Viable
+
Product
Enter fullscreen mode Exit fullscreen mode

I would describe a Minimum Viable System as:

Minimum
+
Useful
+
Safe
+
Observable
+
Changeable
+
Deployable
+
System
Enter fullscreen mode Exit fullscreen mode

The product is what users experience.

The system is everything required to make that experience reliable.

For example, suppose you're building a simple marketplace.

The MVP might be:

User
 ↓
Browse Products
 ↓
Add to Cart
 ↓
Checkout
Enter fullscreen mode Exit fullscreen mode

But the Minimum Viable System is:

                   Marketplace
                       │
       ┌───────────────┼───────────────┐
       ▼               ▼               ▼
   Frontend         Backend         Database
                       │
             ┌─────────┼─────────┐
             ▼         ▼         ▼
          Auth       Orders    Payments
                       │
                       ▼
                  Observability
                       │
                       ▼
                   Deployment
Enter fullscreen mode Exit fullscreen mode

You don't need everything.

But the things you do build need to form a coherent system.


The First Principle: Remove Features, Not Engineering

This is probably the most important idea in the entire article.

When you're trying to ship early, remove scope.

Don't automatically remove engineering discipline.

For example, you might decide:

No social login.
No mobile application.
No recommendation engine.
No advanced analytics.
No multi-language support.
No marketplace messaging.
Enter fullscreen mode Exit fullscreen mode

Good.

That's scope reduction.

But don't decide:

No authentication.
No validation.
No database constraints.
No backups.
No error handling.
No logs.
Enter fullscreen mode Exit fullscreen mode

That's not scope reduction.

That's risk accumulation.

A useful way to think about it is:

                 PRODUCT SCOPE
                       │
             ┌─────────┴─────────┐
             ▼                   ▼
          Remove               Keep
          Features             Foundations
             │                   │
             ▼                   ▼
        Faster Launch        Safer System
Enter fullscreen mode Exit fullscreen mode

The trick is knowing which is which.


What Can Be Cheap?

Almost everything visible to the user can be simplified.

Suppose you're building a dashboard.

You don't need:

100 chart types
Real-time WebSockets
Custom themes
AI analytics
Advanced filtering
Drag-and-drop layouts
Enter fullscreen mode Exit fullscreen mode

You might only need:

Three metrics
One table
One chart
One filter
Enter fullscreen mode Exit fullscreen mode

That's perfectly reasonable.

But your backend should still have:

Input validation
Authentication
Authorization
Database constraints
Error handling
Logging
Enter fullscreen mode Exit fullscreen mode

The interface can be primitive.

The foundations should be intentional.


Think in System Invariants

One of the best ways to build a small system without building a fragile system is to define invariants.

An invariant is something that should always be true.

For an e-commerce system:

An order must belong to a user.

An order must contain at least one item.

An order item must reference a valid product.

A paid order cannot become unpaid accidentally.

A product price must not be negative.
Enter fullscreen mode Exit fullscreen mode

These rules are more important than whether the interface has a beautiful animation.


Example: Database Invariants

Suppose we have:

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

We can strengthen the system:

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL
        CHECK (price_cents >= 0)
);
Enter fullscreen mode Exit fullscreen mode

Now the database itself protects an invariant.

This is important because your application code isn't the only thing touching your data forever.

Eventually you'll have:

Backend
Admin panel
Scripts
Workers
Imports
Migrations
Analytics jobs
Enter fullscreen mode Exit fullscreen mode

Database constraints are a final line of defense.


Don't Build a Cathedral

One of the biggest mistakes I've seen in early software development is architecture designed for imaginary scale.

The developer thinks:

"What if we get ten million users?"

So they build:

API Gateway
 ↓
Auth Service
 ↓
User Service
 ↓
Product Service
 ↓
Order Service
 ↓
Event Bus
 ↓
Kafka
 ↓
Multiple databases
 ↓
Redis cluster
 ↓
Kubernetes
 ↓
Service mesh
Enter fullscreen mode Exit fullscreen mode

The product has:

12 users.
Enter fullscreen mode Exit fullscreen mode

This is architecture fiction.

The architecture should reflect the problem you actually have.


Start With the Boring Architecture

For many products, I would start with:

                 Internet
                    │
                    ▼
                 Nginx
                    │
                    ▼
              Application
                    │
              ┌─────┴─────┐
              ▼           ▼
           Database      Redis
Enter fullscreen mode Exit fullscreen mode

Maybe you don't even need Redis.

Maybe:

Internet
   ↓
Application
   ↓
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

is enough.

There is nothing embarrassing about this.

A well-designed monolith can take you surprisingly far.

The goal is not architectural complexity.

The goal is architectural clarity.


A Good Monolith Has Boundaries

A monolith doesn't mean:

Everything everywhere.
Enter fullscreen mode Exit fullscreen mode

It can still have internal modules.

For example:

app/
├── users/
├── products/
├── orders/
├── payments/
├── notifications/
└── shared/
Enter fullscreen mode Exit fullscreen mode

The application is one deployable unit.

But the concepts are separated.

             Application
                  │
     ┌────────────┼────────────┐
     ▼            ▼            ▼
   Users       Products      Orders
                                │
                                ▼
                            Payments
Enter fullscreen mode Exit fullscreen mode

This is a very powerful starting point.

If one day the order subsystem genuinely needs to become independent, you already have a conceptual boundary.


Implementation: A Small Modular Backend

Here's a simplified Python-style structure:

class UserService:
    def create_user(self, email, password):
        ...

class ProductService:
    def create_product(self, name, price):
        ...

class OrderService:
    def create_order(self, user_id, items):
        ...

class PaymentService:
    def charge(self, order_id, amount):
        ...
Enter fullscreen mode Exit fullscreen mode

The services can live inside one application.

You don't need a network boundary between them yet.

That is an important distinction:

Logical boundary
≠
Network boundary
Enter fullscreen mode Exit fullscreen mode

Create the logical boundary first.

Introduce distributed infrastructure only when the problem demands it.


The Minimum Viable Data Model

Early developers sometimes focus heavily on screens.

I usually think about the data first.

Ask:

What entities exist?

What relationships exist?

What must always be true?

What changes over time?

What needs history?
Enter fullscreen mode Exit fullscreen mode

For a marketplace:

User
 │
 └────< Order
           │
           └────< OrderItem >──── Product
Enter fullscreen mode Exit fullscreen mode

A simple relational model might be:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL
        CHECK (price_cents >= 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL
        REFERENCES users(id),
    status TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL
        REFERENCES orders(id),
    product_id BIGINT NOT NULL
        REFERENCES products(id),
    quantity INTEGER NOT NULL
        CHECK (quantity > 0),
    price_cents INTEGER NOT NULL
        CHECK (price_cents >= 0)
);
Enter fullscreen mode Exit fullscreen mode

Notice something important.

This is not over-engineered.

It's just explicit.


Why Store the Price on Order Items?

Someone might ask:

"Why not always read the current product price?"

Because historical data matters.

Suppose:

Product today: $20
Product yesterday: $15
Enter fullscreen mode Exit fullscreen mode

A customer bought it yesterday.

The order should still represent:

$15
Enter fullscreen mode Exit fullscreen mode

not:

Current product price = $20
Enter fullscreen mode Exit fullscreen mode

This is a small modeling decision.

But these are the kinds of decisions that separate durable software from fragile software.


Build the Smallest Correct Workflow

Let's say we need checkout.

Don't build:

Coupons
Gift cards
Subscriptions
Loyalty points
Multiple currencies
Installments
Wallets
Enter fullscreen mode Exit fullscreen mode

Start with:

Cart
 ↓
Create Order
 ↓
Calculate Total
 ↓
Payment
 ↓
Mark Paid
Enter fullscreen mode Exit fullscreen mode

The workflow should be correct.

                 Checkout
                    │
                    ▼
              Validate Cart
                    │
                    ▼
              Create Order
                    │
                    ▼
             Calculate Total
                    │
                    ▼
              Process Payment
                    │
             ┌──────┴──────┐
             ▼             ▼
          Success        Failure
             │             │
             ▼             ▼
        Mark Paid       Mark Failed
Enter fullscreen mode Exit fullscreen mode

This is enough to learn.


Transactions Matter Early

Imagine this:

Create order
   ↓
Payment succeeds
   ↓
Server crashes
   ↓
Order isn't marked paid
Enter fullscreen mode Exit fullscreen mode

Now the external payment provider says:

PAID
Enter fullscreen mode Exit fullscreen mode

Your database says:

PENDING
Enter fullscreen mode Exit fullscreen mode

You have created an inconsistency.

You don't need microservices to encounter distributed systems problems.

They arrive naturally.


Use Transactions Where They Make Sense

For local database operations:

with db.transaction():

    order = create_order(user_id)

    for item in items:
        create_order_item(order.id, item)

    update_order_total(order.id, total)
Enter fullscreen mode Exit fullscreen mode

If something fails:

Operation
   ↓
Failure
   ↓
Rollback
Enter fullscreen mode Exit fullscreen mode

instead of:

Order created
Order item 1 created
Order item 2 created
Order item 3 failed
Enter fullscreen mode Exit fullscreen mode

Partial state is one of the easiest ways to create early technical debt.


But Don't Pretend External APIs Are Transactional

A payment provider isn't your database.

This won't magically work:

with db.transaction():

    order = create_order()

    payment_provider.charge()

    mark_paid()
Enter fullscreen mode Exit fullscreen mode

If the payment succeeds and your transaction rolls back, the payment still happened.

This is where idempotency and state machines become important.


Build Explicit State Machines

Instead of random booleans:

is_paid
is_cancelled
is_refunded
Enter fullscreen mode Exit fullscreen mode

use a clear state:

PENDING
PAID
FAILED
CANCELLED
REFUNDED
Enter fullscreen mode Exit fullscreen mode

And define valid transitions:

              ┌─────────┐
              │ PENDING │
              └────┬────┘
             ┌─────┴─────┐
             ▼           ▼
           PAID        FAILED
             │
             ▼
          REFUNDED
Enter fullscreen mode Exit fullscreen mode

Then implementation becomes explicit:

VALID_TRANSITIONS = {
    "PENDING": {"PAID", "FAILED", "CANCELLED"},
    "PAID": {"REFUNDED"},
    "FAILED": set(),
    "CANCELLED": set(),
    "REFUNDED": set(),
}
Enter fullscreen mode Exit fullscreen mode

Now your system has a model of reality.


Idempotency: One of the Cheapest High-Value Investments

Imagine the user clicks:

Pay
Enter fullscreen mode Exit fullscreen mode

The request reaches the server.

The payment succeeds.

The response gets lost.

The client retries.

Now you might charge them twice.

This is why idempotency matters.

A request can include:

Idempotency-Key: 7b1a-92f4
Enter fullscreen mode Exit fullscreen mode

The backend stores the result:

7b1a-92f4
     ↓
Payment #9281
     ↓
SUCCESS
Enter fullscreen mode Exit fullscreen mode

If the request arrives again:

Same key
   ↓
Existing result
   ↓
Return previous result
Enter fullscreen mode Exit fullscreen mode

No duplicate payment.

This is not premature optimization.

It's correctness.


The Minimum Viable API

Don't build 100 endpoints.

Start with the smallest workflow.

For a marketplace:

POST /auth/register
POST /auth/login

GET /products
GET /products/:id

POST /orders
GET /orders/:id

POST /payments
Enter fullscreen mode Exit fullscreen mode

That's enough to launch a surprisingly functional system.

But each endpoint should have:

Validation
Authentication
Authorization
Consistent errors
Logging
Tests
Enter fullscreen mode Exit fullscreen mode

API Error Design

Avoid this:

{
  "error": "Something went wrong"
}
Enter fullscreen mode Exit fullscreen mode

Prefer structured errors:

{
  "error": {
    "code": "INVALID_ORDER",
    "message": "Order contains no items",
    "request_id": "req_82a91"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the client can understand what happened.

The request ID also helps debugging.


Implementation: Request IDs

A simple middleware pattern:

import uuid

def request_id_middleware(request, next_handler):

    request_id = request.headers.get(
        "X-Request-ID"
    ) or str(uuid.uuid4())

    response = next_handler(request)

    response.headers["X-Request-ID"] = request_id

    return response
Enter fullscreen mode Exit fullscreen mode

Then logs can contain:

request_id=req_82a91
Enter fullscreen mode Exit fullscreen mode

Now one customer issue can be traced across the system.


Observability Is Not a Luxury

You don't need a massive observability platform on day one.

But you need to know:

What happened?
When?
To whom?
Where?
Why?
Enter fullscreen mode Exit fullscreen mode

At minimum:

Logs
Errors
Request IDs
Basic metrics
Enter fullscreen mode Exit fullscreen mode

A simple log:

logger.info(
    "order_created",
    extra={
        "order_id": order.id,
        "user_id": user.id,
        "request_id": request_id
    }
)
Enter fullscreen mode Exit fullscreen mode

Now you have a trail.


The Minimum Viable Metrics

I would start with:

Requests
Errors
Latency
Active users
Successful transactions
Failed transactions
Enter fullscreen mode Exit fullscreen mode

That's enough to answer many early questions.

For example:

Requests:       12,430
Errors:             83
Error rate:       0.67%
P95 latency:       420ms
Orders:             218
Payments failed:     11
Enter fullscreen mode Exit fullscreen mode

This is far more useful than a dashboard containing 200 meaningless metrics.


Testing: Don't Test Everything

Another misunderstanding of MVP development is:

"We don't have time for tests."

You don't have time for the wrong tests.

You don't need hundreds of tests for every CSS component.

Test the business invariants.

For example:

def test_order_requires_items():
    with pytest.raises(ValueError):
        create_order(user_id=1, items=[])
Enter fullscreen mode Exit fullscreen mode

Test:

def test_negative_price_is_rejected():
    with pytest.raises(ValueError):
        create_product(
            name="Laptop",
            price_cents=-100
        )
Enter fullscreen mode Exit fullscreen mode

Test state transitions:

def test_paid_order_can_be_refunded():

    order = Order(status="PAID")

    order.transition("REFUNDED")

    assert order.status == "REFUNDED"
Enter fullscreen mode Exit fullscreen mode

And invalid transitions:

def test_pending_order_cannot_be_refunded():

    order = Order(status="PENDING")

    with pytest.raises(ValueError):
        order.transition("REFUNDED")
Enter fullscreen mode Exit fullscreen mode

These tests protect the system's meaning.


Test the Expensive-to-Fix Things

A useful rule:

Test behavior that would be expensive or embarrassing to break.

Examples:

Authentication
Payments
Permissions
Data integrity
Orders
Billing
State transitions
Important calculations
Enter fullscreen mode Exit fullscreen mode

Don't obsess over achieving:

100% code coverage
Enter fullscreen mode Exit fullscreen mode

while missing:

100% correctness of money movement
Enter fullscreen mode Exit fullscreen mode

Coverage is a metric.

Correctness is the goal.


Security Must Exist Before Users

One of the worst MVP philosophies is:

"We'll add security after we validate the idea."

Users don't care whether you're validating an idea.

If their passwords are leaked, they're still leaked.

The minimum system should include:

HTTPS
Password hashing
Authentication
Authorization
Input validation
Secure secrets
Database access controls
Rate limiting where necessary
Dependency updates
Enter fullscreen mode Exit fullscreen mode

You don't need a giant security department.

You do need basic security hygiene.


Never Store Plaintext Passwords

This should be non-negotiable.

Don't:

user.password = password
Enter fullscreen mode Exit fullscreen mode

Use a password hashing algorithm through a reputable library.

Conceptually:

password_hash = hash_password(password)

verify_password(
    password,
    password_hash
)
Enter fullscreen mode Exit fullscreen mode

The database should never contain the original password.


Authorization Is Different From Authentication

Authentication asks:

Who are you?

Authorization asks:

What are you allowed to do?

For example:

User A
 ↓
GET /orders/123
Enter fullscreen mode Exit fullscreen mode

The system shouldn't simply check:

Is User A logged in?
Enter fullscreen mode Exit fullscreen mode

It should check:

Does Order 123 belong to User A?
Enter fullscreen mode Exit fullscreen mode

A simple rule:

if order.user_id != current_user.id:
    raise Forbidden()
Enter fullscreen mode Exit fullscreen mode

Many security vulnerabilities are simply authorization mistakes.


Don't Build Your Own Cryptography

This is another place where "minimum viable" can become dangerous.

Don't implement:

Your own password hashing
Your own encryption algorithm
Your own JWT implementation
Your own TLS
Enter fullscreen mode Exit fullscreen mode

Use established libraries.

Build your product.

Not your own cryptographic primitive.


Deployment Is Part of the Product

A system that works on your laptop isn't a product.

This:

Works on my machine
Enter fullscreen mode Exit fullscreen mode

is not deployment.

A minimum viable system needs a repeatable path:

Git
 ↓
Build
 ↓
Test
 ↓
Deploy
 ↓
Health Check
Enter fullscreen mode Exit fullscreen mode

Even if you're the only developer.


A Minimal Deployment Pipeline

                 Git Push
                    │
                    ▼
                  CI
                    │
          ┌─────────┴─────────┐
          ▼                   ▼
       Tests              Build
          │                   │
          └─────────┬─────────┘
                    ▼
                 Deploy
                    │
                    ▼
               Health Check
                    │
              ┌─────┴─────┐
              ▼           ▼
           Healthy      Failed
              │           │
              ▼           ▼
            Live       Rollback
Enter fullscreen mode Exit fullscreen mode

This is enough.

You don't need an elaborate platform team.


Health Checks

A simple endpoint:

GET /health
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "status": "ok"
}
Enter fullscreen mode Exit fullscreen mode

But a deeper readiness check might verify:

Database
Redis
Critical dependencies
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "status": "ready",
  "database": "ok"
}
Enter fullscreen mode Exit fullscreen mode

This becomes extremely useful during deployments.


Feature Flags Are Extremely Powerful

You don't always have to choose:

Build feature
OR
Don't build feature
Enter fullscreen mode Exit fullscreen mode

You can build:

Feature
 ↓
Disabled
 ↓
Deploy
 ↓
Enable for developers
 ↓
Enable for 5%
 ↓
Enable for 50%
 ↓
Enable for everyone
Enter fullscreen mode Exit fullscreen mode

Example:

if feature_flags.enabled(
    "new_checkout",
    user_id
):
    return new_checkout()

return old_checkout()
Enter fullscreen mode Exit fullscreen mode

Now deployment and release become separate concepts.

This gives you much more control.


The Minimum Viable System Should Be Observable Before It Is Scaled

This is a principle I strongly believe in.

Don't scale something you can't understand.

Suppose your application gets slow.

If you have:

No logs
No metrics
No tracing
No profiling
Enter fullscreen mode Exit fullscreen mode

you don't know why.

You start guessing.

Then you add:

Redis
Enter fullscreen mode Exit fullscreen mode

It doesn't fix the problem.

Then:

More servers
Enter fullscreen mode Exit fullscreen mode

Still doesn't fix it.

Then:

Database replica
Enter fullscreen mode Exit fullscreen mode

Still broken.

You are scaling blind.


Measure Before Optimizing

A simple workflow:

Problem
  ↓
Measure
  ↓
Identify bottleneck
  ↓
Change
  ↓
Measure again
Enter fullscreen mode Exit fullscreen mode

Not:

Problem
 ↓
Add technology
 ↓
Hope
Enter fullscreen mode Exit fullscreen mode

The Database Is Often Your First Scaling Problem

Suppose your query is:

SELECT *
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;
Enter fullscreen mode Exit fullscreen mode

If the table grows, indexing matters.

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

This is often more valuable than introducing another service.

A good database design can eliminate entire categories of infrastructure complexity.


Don't Prematurely Cache Everything

Caching is useful.

But it introduces invalidation problems.

Start with:

Database
Enter fullscreen mode Exit fullscreen mode

Measure.

If a query becomes expensive:

Optimize query
 ↓
Add index
 ↓
Measure
 ↓
Cache if necessary
Enter fullscreen mode Exit fullscreen mode

Not:

Everything → Redis
Enter fullscreen mode Exit fullscreen mode

The Minimum Viable System Has a Debt Budget

Technical debt isn't automatically bad.

Sometimes debt is intentional.

For example:

We use a simple email implementation now.
We'll build a notification service later.
Enter fullscreen mode Exit fullscreen mode

That's reasonable.

But record the debt.

Create a simple document:

TECHNICAL DEBT

1. Replace temporary email provider
2. Add stronger rate limiting
3. Refactor reporting query
4. Improve background job retries
Enter fullscreen mode Exit fullscreen mode

Now debt is visible.

Invisible debt is much more dangerous than documented debt.


Distinguish Good Debt From Bad Debt

Good debt

Simple architecture
Temporary UI
Manual admin process
Limited feature set
Basic deployment
Enter fullscreen mode Exit fullscreen mode

These can be improved later.

Bad debt

Security vulnerability
Corruptible data
Unclear ownership
No backups
No migration strategy
Duplicated business logic everywhere
Enter fullscreen mode Exit fullscreen mode

These become exponentially more expensive.


A Useful Equation

I think about early software roughly like this:

Future Change Cost
≈
Complexity
×
Coupling
×
Uncertainty
Enter fullscreen mode Exit fullscreen mode

You can't eliminate uncertainty.

You're building something new.

But you can reduce:

Complexity
Coupling
Enter fullscreen mode Exit fullscreen mode

That's what good MVP architecture does.


Build for Change, Not for Scale

This distinction is extremely important.

Early products usually don't need:

10 million requests/second
Enter fullscreen mode Exit fullscreen mode

They need:

The ability to change direction quickly.
Enter fullscreen mode Exit fullscreen mode

You might discover that users don't want feature A.

They want feature B.

If your architecture is easy to change:

A → B
Enter fullscreen mode Exit fullscreen mode

is manageable.

If your architecture is tightly coupled:

A
 ↓
Service 1
 ↓
Service 2
 ↓
Service 3
 ↓
Database
 ↓
Event bus
 ↓
Service 4
Enter fullscreen mode Exit fullscreen mode

changing A can become a project of its own.


The Real Enemy Is Coupling

Consider this:

def create_order():
    send_email()
    charge_payment()
    update_inventory()
    create_analytics_event()
    notify_admin()
Enter fullscreen mode Exit fullscreen mode

One function now knows everything.

That's coupling.

A cleaner approach:

def create_order():
    order = order_service.create()
    events.publish(
        OrderCreated(order.id)
    )
    return order
Enter fullscreen mode Exit fullscreen mode

Then other systems can react.

Even inside a monolith, this creates cleaner boundaries.


But Don't Turn Everything Into Events

Again, balance.

This:

create order
 ↓
event
 ↓
handler
 ↓
event
 ↓
handler
 ↓
event
Enter fullscreen mode Exit fullscreen mode

can become difficult to reason about.

Use events where asynchronous behavior genuinely helps.

For example:

OrderCreated
 ↓
Send email
 ↓
Update analytics
Enter fullscreen mode Exit fullscreen mode

Those operations don't necessarily need to block checkout.

But:

Calculate order total
Enter fullscreen mode Exit fullscreen mode

should probably remain part of the immediate transaction.


Synchronous vs Asynchronous

A useful early rule:

Synchronous

Use when the user needs the result immediately.

Create order
Calculate total
Validate permissions
Enter fullscreen mode Exit fullscreen mode

Asynchronous

Use when the work can happen later.

Send email
Generate report
Resize image
Process analytics
Enter fullscreen mode Exit fullscreen mode

This keeps the core workflow simple.


Background Jobs Without Overengineering

You might start with:

Application
 ↓
Database
Enter fullscreen mode Exit fullscreen mode

Then introduce a simple queue when needed:

Application
 ↓
Queue
 ↓
Worker
Enter fullscreen mode Exit fullscreen mode

For example:

jobs.enqueue(
    send_order_confirmation,
    order.id
)
Enter fullscreen mode Exit fullscreen mode

The worker handles:

def send_order_confirmation(order_id):

    order = get_order(order_id)

    email.send(
        to=order.user.email,
        template="order_confirmation"
    )
Enter fullscreen mode Exit fullscreen mode

Now the checkout request doesn't wait for email delivery.


Retry Carefully

Background jobs can fail.

Use:

Attempt 1
 ↓
Failure
 ↓
Attempt 2
 ↓
Failure
 ↓
Attempt 3
 ↓
Dead Letter Queue
Enter fullscreen mode Exit fullscreen mode

But make the job idempotent.

Otherwise:

Retry
 ↓
Duplicate action
Enter fullscreen mode Exit fullscreen mode

can become a serious problem.


The Minimum Viable Admin System

This is something developers often overlook.

You need a way to operate your own product.

At minimum:

View users
View orders
View errors
View important records
Disable problematic accounts
Retry failed jobs
Enter fullscreen mode Exit fullscreen mode

You don't need a beautiful admin dashboard.

A basic internal interface can save enormous amounts of time.


Build Manual Processes Before Automating Them

Suppose you're unsure whether refunds should be automatic.

Don't spend two weeks building refund automation.

Start with:

Customer requests refund
 ↓
Admin reviews
 ↓
Admin clicks refund
Enter fullscreen mode Exit fullscreen mode

Once you have enough volume:

Automated Refund
Enter fullscreen mode Exit fullscreen mode

The manual process teaches you the rules.

This is one of the most powerful forms of product discovery.


Automation Should Follow Understanding

The sequence should often be:

Manual
 ↓
Understand
 ↓
Standardize
 ↓
Automate
Enter fullscreen mode Exit fullscreen mode

Not:

Unknown Process
 ↓
Automate
 ↓
Discover Problems
Enter fullscreen mode Exit fullscreen mode

Automation amplifies understanding.

It also amplifies confusion.


The Minimum Viable System Is a Learning Machine

This is the deeper idea.

You're not merely building:

Product
Enter fullscreen mode Exit fullscreen mode

You're building:

Product
+
Feedback Loop
Enter fullscreen mode Exit fullscreen mode

The system should teach you.

                    USERS
                      │
                      ▼
                   PRODUCT
                      │
             ┌────────┼────────┐
             ▼        ▼        ▼
          Metrics   Feedback   Errors
             │        │        │
             └────────┼────────┘
                      ▼
                  INSIGHTS
                      │
                      ▼
                    CHANGE
                      │
                      ▼
                   PRODUCT
Enter fullscreen mode Exit fullscreen mode

This is why observability, analytics and customer feedback matter so much.

They aren't side projects.

They are part of the learning system.


What I Would Build in the First Two Weeks

If I were building a new backend-heavy product, my initial system might look like this.

Day 1–2: Domain

Define:

Users
Core entities
Relationships
Important states
Business rules
Enter fullscreen mode Exit fullscreen mode

Draw the data model.

User
 │
 ├── Orders
 │     │
 │     └── Items
 │
 └── Profile
Enter fullscreen mode Exit fullscreen mode

Day 3–4: Database

Implement:

Tables
Primary keys
Foreign keys
Constraints
Indexes
Migrations
Enter fullscreen mode Exit fullscreen mode

Don't obsess over optimization.

Make the model correct.


Day 5–6: Core API

Implement only:

Authentication
Core resource creation
Core resource retrieval
Primary workflow
Enter fullscreen mode Exit fullscreen mode

No 50-endpoint REST API.


Day 7: Tests

Test:

Core business rules
Important failure cases
Authorization
Data invariants
Enter fullscreen mode Exit fullscreen mode

Day 8: Deployment

Create:

CI
Production environment
Environment variables
Database migrations
Health check
Enter fullscreen mode Exit fullscreen mode

Day 9: Observability

Add:

Structured logs
Request IDs
Error tracking
Basic metrics
Enter fullscreen mode Exit fullscreen mode

Day 10: Launch

Give it to real users.

Not after six months.

Now.


Then Let Reality Drive Development

After launch, don't immediately build 30 more features.

Watch.

Ask:

Where do users struggle?

What do they actually use?

Where do they abandon the workflow?

What breaks?

What do they request repeatedly?

What are they willing to pay for?
Enter fullscreen mode Exit fullscreen mode

Then build.


The Product Roadmap Should Come From Evidence

A bad roadmap:

AI
Blockchain
Mobile app
Social feed
NFTs
Recommendations
Gamification
Enter fullscreen mode Exit fullscreen mode

A better roadmap:

Users cannot complete checkout
 ↓
Fix checkout

Users repeatedly search for invoices
 ↓
Add invoices

Customers ask for export
 ↓
Add CSV export
Enter fullscreen mode Exit fullscreen mode

The system tells you where to go.


Don't Confuse "Small" With "Cheap"

A small system can still be high quality.

For example:

One backend
One database
One frontend
One deployment
Enter fullscreen mode Exit fullscreen mode

can be extremely well engineered.

And:

12 services
Kubernetes
Kafka
Redis
GraphQL
Service mesh
Enter fullscreen mode Exit fullscreen mode

can be terrible.

Architecture quality is not proportional to architecture size.


The Smallest Good System

If I had to reduce the idea of a Minimum Viable System to one architecture, it would be:

                 USERS
                   │
                   ▼
                FRONTEND
                   │
                   ▼
                API
                   │
          ┌────────┼────────┐
          ▼        ▼        ▼
       Domain    Auth     Validation
       Logic
          │
          ▼
       Database
          │
          ▼
        Migrations

Supporting:
    Logs
    Metrics
    Tests
    Backups
    CI/CD
Enter fullscreen mode Exit fullscreen mode

That's it.

No Kubernetes.

No microservices.

No event streaming platform.

No AI agent swarm.

No infrastructure theater.

Just a coherent system.


The Minimum Viable System Checklist

Before launching, I would ask:

Product

[ ] Is the core user problem clear?
[ ] Can a user complete the main workflow?
[ ] Is there a measurable outcome?
Enter fullscreen mode Exit fullscreen mode

Data

[ ] Are relationships modeled correctly?
[ ] Are important constraints enforced?
[ ] Are migrations versioned?
[ ] Are backups configured?
Enter fullscreen mode Exit fullscreen mode

Backend

[ ] Are APIs validated?
[ ] Are errors structured?
[ ] Are permissions enforced?
[ ] Are important operations transactional?
Enter fullscreen mode Exit fullscreen mode

Security

[ ] Are passwords hashed?
[ ] Is HTTPS enabled?
[ ] Are secrets outside source control?
[ ] Is authorization checked?
[ ] Are dangerous inputs validated?
Enter fullscreen mode Exit fullscreen mode

Reliability

[ ] Are errors logged?
[ ] Are request IDs available?
[ ] Is there a health endpoint?
[ ] Can failed jobs be retried?
Enter fullscreen mode Exit fullscreen mode

Deployment

[ ] Can the system be deployed repeatably?
[ ] Can database migrations run safely?
[ ] Is rollback possible?
Enter fullscreen mode Exit fullscreen mode

Product Learning

[ ] Can we measure usage?
[ ] Can users give feedback?
[ ] Can we identify failures?
[ ] Can we understand why users leave?
Enter fullscreen mode Exit fullscreen mode

If these answers are mostly yes, you probably have something much more valuable than a typical MVP.

You have a Minimum Viable System.


What I Would Not Build

At the beginning, I would probably avoid:

Microservices
Kubernetes
Custom authentication protocol
Custom payment infrastructure
Complex event-driven architecture
Premature caching
Multi-region deployment
Multiple databases
Distributed tracing infrastructure
AI agents
Complex recommendation engines
Enter fullscreen mode Exit fullscreen mode

Unless the actual problem requires them.

Not because these technologies are bad.

They're excellent technologies.

But technology should answer a problem.

It shouldn't become the problem.


The "Future Scale" Test

Before adding infrastructure, ask three questions:

1. What problem am I solving?

Not:

"Would Redis be cool?"

But:

"Which measured bottleneck requires Redis?"

2. Do I actually have the problem?

Not:

"What if we have millions of users?"

But:

"Do our current measurements indicate that the database is the bottleneck?"

3. What's the simplest solution?

Maybe the answer is:

Add an index.
Enter fullscreen mode Exit fullscreen mode

Not:

Create another distributed service.
Enter fullscreen mode Exit fullscreen mode

This discipline can save months.


The Most Valuable Feature Is Sometimes "Nothing"

This is something I learned to appreciate more with experience.

A developer sees an empty space in the product and thinks:

"We should build something here."

But sometimes the best decision is:

Don't build it.
Enter fullscreen mode Exit fullscreen mode

Every feature adds:

Code
Tests
Documentation
UI
Data
Support
Security
Maintenance
Enter fullscreen mode Exit fullscreen mode

The cost isn't just development.

It's permanent ownership.


Every Feature Becomes a Contract

Once users depend on something, removing it becomes difficult.

For example:

Feature
 ↓
Users depend on it
 ↓
Data accumulates
 ↓
Documentation exists
 ↓
Integrations appear
 ↓
Support depends on it
Enter fullscreen mode Exit fullscreen mode

Now the feature is part of the system.

This is why early product development should be ruthless about scope.

You're not just choosing what to build.

You're choosing what you will have to maintain.


Software Is a Long-Term Commitment

Writing code is easy compared with maintaining code.

A hundred lines today might become:

100 lines
 ↓
500 lines
 ↓
3 developers
 ↓
10 integrations
 ↓
100 customers
 ↓
Production dependency
Enter fullscreen mode Exit fullscreen mode

This is why I care about the first architecture.

Not because it needs to be perfect.

But because it establishes the direction.


The First Version Is a Seed

I like thinking about software like a seed.

You don't need to build the entire tree.

But you shouldn't plant something that is fundamentally malformed.

A good seed has:

Clear structure
Healthy boundaries
Correct foundations
Room to grow
Enter fullscreen mode Exit fullscreen mode

The first version of your product should work the same way.

               VERSION 1
                   │
          ┌────────┼────────┐
          ▼        ▼        ▼
       Users      Data     Logic
          │        │        │
          └────────┼────────┘
                   ▼
                Feedback
                   │
                   ▼
               VERSION 2
                   │
                   ▼
               VERSION 3
                   │
                   ▼
              REAL SYSTEM
Enter fullscreen mode Exit fullscreen mode

You don't know what version 10 will look like.

That's okay.

Your job is to make version 1 good enough to discover version 2.


Shipping Early Is About Managing Uncertainty

This is the part of MVP thinking that I think gets lost.

You're not shipping early because you don't care about quality.

You're shipping early because you don't yet know enough to justify building everything.

That's a completely different philosophy.

High uncertainty
      +
Small system
      ↓
Fast feedback
      ↓
More knowledge
      ↓
Better architecture
      ↓
Better product
Enter fullscreen mode Exit fullscreen mode

The mistake is thinking:

High uncertainty
      +
Huge system
      ↓
Success
Enter fullscreen mode Exit fullscreen mode

Usually you just get an expensive guess.


The Best Early Architecture Is a Feedback Architecture

This is ultimately what I mean by Minimum Viable System.

Build enough to:

Create value
Collect data
Observe behavior
Protect users
Change quickly
Enter fullscreen mode Exit fullscreen mode

Everything else can wait.

Your architecture should make the feedback loop fast.

                  IDEA
                   │
                   ▼
                BUILD
                   │
                   ▼
                SHIP
                   │
                   ▼
                USERS
                   │
                   ▼
                MEASURE
                   │
                   ▼
                LEARN
                   │
                   ▼
                CHANGE
                   │
                   └───────────────► BUILD
Enter fullscreen mode Exit fullscreen mode

That's the real engine of an early-stage product.


Final Thoughts

I no longer think the goal of early software development should be to build an MVP.

The phrase has become too easy to misunderstand.

The goal should be to build a Minimum Viable System.

A system that is small enough to build quickly.

Simple enough to understand.

Reliable enough to trust.

Secure enough to expose to users.

Observable enough to debug.

Flexible enough to change.

And incomplete enough to leave room for discovery.

The philosophy is simple:

Reduce Scope
     ↓
Keep Foundations
     ↓
Ship
     ↓
Observe
     ↓
Learn
     ↓
Improve
Enter fullscreen mode Exit fullscreen mode

Don't remove engineering just because the product is young.

Remove unnecessary product complexity.

Don't build for imaginary millions of users.

Build for the users you actually have.

Don't build ten services because you might need them someday.

Build one good system with boundaries.

Don't automate processes you don't understand.

Do them manually until the pattern becomes obvious.

Don't test every line of code.

Test the rules that define whether your product is correct.

Don't optimize everything.

Measure first.

Don't aim for perfect architecture.

Aim for architecture that can evolve.

And most importantly:

Don't confuse shipping early with shipping carelessly.

A Minimum Viable System is not an excuse for bad engineering.

It is a strategy for focused engineering.

You are deliberately saying:

"We don't know everything yet, so we won't build everything yet."

But you're also saying:

"The things we do build will have foundations strong enough to survive what we learn."

That is the balance.

Because the real goal of version one isn't to prove that you can write software.

It's to create enough reality that the software can teach you what to build next.

And that's the difference between a prototype that gets thrown away...

and a small system that eventually becomes a real product.

Top comments (0)