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.
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
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
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
I would describe a Minimum Viable System as:
Minimum
+
Useful
+
Safe
+
Observable
+
Changeable
+
Deployable
+
System
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
But the Minimum Viable System is:
Marketplace
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Frontend Backend Database
│
┌─────────┼─────────┐
▼ ▼ ▼
Auth Orders Payments
│
▼
Observability
│
▼
Deployment
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.
Good.
That's scope reduction.
But don't decide:
No authentication.
No validation.
No database constraints.
No backups.
No error handling.
No logs.
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
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
You might only need:
Three metrics
One table
One chart
One filter
That's perfectly reasonable.
But your backend should still have:
Input validation
Authentication
Authorization
Database constraints
Error handling
Logging
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.
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
);
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)
);
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
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
The product has:
12 users.
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
Maybe you don't even need Redis.
Maybe:
Internet
↓
Application
↓
PostgreSQL
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.
It can still have internal modules.
For example:
app/
├── users/
├── products/
├── orders/
├── payments/
├── notifications/
└── shared/
The application is one deployable unit.
But the concepts are separated.
Application
│
┌────────────┼────────────┐
▼ ▼ ▼
Users Products Orders
│
▼
Payments
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):
...
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
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?
For a marketplace:
User
│
└────< Order
│
└────< OrderItem >──── Product
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)
);
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
A customer bought it yesterday.
The order should still represent:
$15
not:
Current product price = $20
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
Start with:
Cart
↓
Create Order
↓
Calculate Total
↓
Payment
↓
Mark Paid
The workflow should be correct.
Checkout
│
▼
Validate Cart
│
▼
Create Order
│
▼
Calculate Total
│
▼
Process Payment
│
┌──────┴──────┐
▼ ▼
Success Failure
│ │
▼ ▼
Mark Paid Mark Failed
This is enough to learn.
Transactions Matter Early
Imagine this:
Create order
↓
Payment succeeds
↓
Server crashes
↓
Order isn't marked paid
Now the external payment provider says:
PAID
Your database says:
PENDING
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)
If something fails:
Operation
↓
Failure
↓
Rollback
instead of:
Order created
Order item 1 created
Order item 2 created
Order item 3 failed
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()
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
use a clear state:
PENDING
PAID
FAILED
CANCELLED
REFUNDED
And define valid transitions:
┌─────────┐
│ PENDING │
└────┬────┘
┌─────┴─────┐
▼ ▼
PAID FAILED
│
▼
REFUNDED
Then implementation becomes explicit:
VALID_TRANSITIONS = {
"PENDING": {"PAID", "FAILED", "CANCELLED"},
"PAID": {"REFUNDED"},
"FAILED": set(),
"CANCELLED": set(),
"REFUNDED": set(),
}
Now your system has a model of reality.
Idempotency: One of the Cheapest High-Value Investments
Imagine the user clicks:
Pay
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
The backend stores the result:
7b1a-92f4
↓
Payment #9281
↓
SUCCESS
If the request arrives again:
Same key
↓
Existing result
↓
Return previous result
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
That's enough to launch a surprisingly functional system.
But each endpoint should have:
Validation
Authentication
Authorization
Consistent errors
Logging
Tests
API Error Design
Avoid this:
{
"error": "Something went wrong"
}
Prefer structured errors:
{
"error": {
"code": "INVALID_ORDER",
"message": "Order contains no items",
"request_id": "req_82a91"
}
}
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
Then logs can contain:
request_id=req_82a91
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?
At minimum:
Logs
Errors
Request IDs
Basic metrics
A simple log:
logger.info(
"order_created",
extra={
"order_id": order.id,
"user_id": user.id,
"request_id": request_id
}
)
Now you have a trail.
The Minimum Viable Metrics
I would start with:
Requests
Errors
Latency
Active users
Successful transactions
Failed transactions
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
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=[])
Test:
def test_negative_price_is_rejected():
with pytest.raises(ValueError):
create_product(
name="Laptop",
price_cents=-100
)
Test state transitions:
def test_paid_order_can_be_refunded():
order = Order(status="PAID")
order.transition("REFUNDED")
assert order.status == "REFUNDED"
And invalid transitions:
def test_pending_order_cannot_be_refunded():
order = Order(status="PENDING")
with pytest.raises(ValueError):
order.transition("REFUNDED")
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
Don't obsess over achieving:
100% code coverage
while missing:
100% correctness of money movement
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
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
Use a password hashing algorithm through a reputable library.
Conceptually:
password_hash = hash_password(password)
verify_password(
password,
password_hash
)
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
The system shouldn't simply check:
Is User A logged in?
It should check:
Does Order 123 belong to User A?
A simple rule:
if order.user_id != current_user.id:
raise Forbidden()
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
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
is not deployment.
A minimum viable system needs a repeatable path:
Git
↓
Build
↓
Test
↓
Deploy
↓
Health Check
Even if you're the only developer.
A Minimal Deployment Pipeline
Git Push
│
▼
CI
│
┌─────────┴─────────┐
▼ ▼
Tests Build
│ │
└─────────┬─────────┘
▼
Deploy
│
▼
Health Check
│
┌─────┴─────┐
▼ ▼
Healthy Failed
│ │
▼ ▼
Live Rollback
This is enough.
You don't need an elaborate platform team.
Health Checks
A simple endpoint:
GET /health
Response:
{
"status": "ok"
}
But a deeper readiness check might verify:
Database
Redis
Critical dependencies
For example:
{
"status": "ready",
"database": "ok"
}
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
You can build:
Feature
↓
Disabled
↓
Deploy
↓
Enable for developers
↓
Enable for 5%
↓
Enable for 50%
↓
Enable for everyone
Example:
if feature_flags.enabled(
"new_checkout",
user_id
):
return new_checkout()
return old_checkout()
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
you don't know why.
You start guessing.
Then you add:
Redis
It doesn't fix the problem.
Then:
More servers
Still doesn't fix it.
Then:
Database replica
Still broken.
You are scaling blind.
Measure Before Optimizing
A simple workflow:
Problem
↓
Measure
↓
Identify bottleneck
↓
Change
↓
Measure again
Not:
Problem
↓
Add technology
↓
Hope
The Database Is Often Your First Scaling Problem
Suppose your query is:
SELECT *
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;
If the table grows, indexing matters.
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
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
Measure.
If a query becomes expensive:
Optimize query
↓
Add index
↓
Measure
↓
Cache if necessary
Not:
Everything → Redis
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.
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
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
These can be improved later.
Bad debt
Security vulnerability
Corruptible data
Unclear ownership
No backups
No migration strategy
Duplicated business logic everywhere
These become exponentially more expensive.
A Useful Equation
I think about early software roughly like this:
Future Change Cost
≈
Complexity
×
Coupling
×
Uncertainty
You can't eliminate uncertainty.
You're building something new.
But you can reduce:
Complexity
Coupling
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
They need:
The ability to change direction quickly.
You might discover that users don't want feature A.
They want feature B.
If your architecture is easy to change:
A → B
is manageable.
If your architecture is tightly coupled:
A
↓
Service 1
↓
Service 2
↓
Service 3
↓
Database
↓
Event bus
↓
Service 4
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()
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
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
can become difficult to reason about.
Use events where asynchronous behavior genuinely helps.
For example:
OrderCreated
↓
Send email
↓
Update analytics
Those operations don't necessarily need to block checkout.
But:
Calculate order total
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
Asynchronous
Use when the work can happen later.
Send email
Generate report
Resize image
Process analytics
This keeps the core workflow simple.
Background Jobs Without Overengineering
You might start with:
Application
↓
Database
Then introduce a simple queue when needed:
Application
↓
Queue
↓
Worker
For example:
jobs.enqueue(
send_order_confirmation,
order.id
)
The worker handles:
def send_order_confirmation(order_id):
order = get_order(order_id)
email.send(
to=order.user.email,
template="order_confirmation"
)
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
But make the job idempotent.
Otherwise:
Retry
↓
Duplicate action
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
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
Once you have enough volume:
Automated Refund
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
Not:
Unknown Process
↓
Automate
↓
Discover Problems
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
You're building:
Product
+
Feedback Loop
The system should teach you.
USERS
│
▼
PRODUCT
│
┌────────┼────────┐
▼ ▼ ▼
Metrics Feedback Errors
│ │ │
└────────┼────────┘
▼
INSIGHTS
│
▼
CHANGE
│
▼
PRODUCT
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
Draw the data model.
User
│
├── Orders
│ │
│ └── Items
│
└── Profile
Day 3–4: Database
Implement:
Tables
Primary keys
Foreign keys
Constraints
Indexes
Migrations
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
No 50-endpoint REST API.
Day 7: Tests
Test:
Core business rules
Important failure cases
Authorization
Data invariants
Day 8: Deployment
Create:
CI
Production environment
Environment variables
Database migrations
Health check
Day 9: Observability
Add:
Structured logs
Request IDs
Error tracking
Basic metrics
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?
Then build.
The Product Roadmap Should Come From Evidence
A bad roadmap:
AI
Blockchain
Mobile app
Social feed
NFTs
Recommendations
Gamification
A better roadmap:
Users cannot complete checkout
↓
Fix checkout
Users repeatedly search for invoices
↓
Add invoices
Customers ask for export
↓
Add CSV export
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
can be extremely well engineered.
And:
12 services
Kubernetes
Kafka
Redis
GraphQL
Service mesh
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
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?
Data
[ ] Are relationships modeled correctly?
[ ] Are important constraints enforced?
[ ] Are migrations versioned?
[ ] Are backups configured?
Backend
[ ] Are APIs validated?
[ ] Are errors structured?
[ ] Are permissions enforced?
[ ] Are important operations transactional?
Security
[ ] Are passwords hashed?
[ ] Is HTTPS enabled?
[ ] Are secrets outside source control?
[ ] Is authorization checked?
[ ] Are dangerous inputs validated?
Reliability
[ ] Are errors logged?
[ ] Are request IDs available?
[ ] Is there a health endpoint?
[ ] Can failed jobs be retried?
Deployment
[ ] Can the system be deployed repeatably?
[ ] Can database migrations run safely?
[ ] Is rollback possible?
Product Learning
[ ] Can we measure usage?
[ ] Can users give feedback?
[ ] Can we identify failures?
[ ] Can we understand why users leave?
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
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.
Not:
Create another distributed service.
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.
Every feature adds:
Code
Tests
Documentation
UI
Data
Support
Security
Maintenance
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
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
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
The first version of your product should work the same way.
VERSION 1
│
┌────────┼────────┐
▼ ▼ ▼
Users Data Logic
│ │ │
└────────┼────────┘
▼
Feedback
│
▼
VERSION 2
│
▼
VERSION 3
│
▼
REAL SYSTEM
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
The mistake is thinking:
High uncertainty
+
Huge system
↓
Success
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
Everything else can wait.
Your architecture should make the feedback loop fast.
IDEA
│
▼
BUILD
│
▼
SHIP
│
▼
USERS
│
▼
MEASURE
│
▼
LEARN
│
▼
CHANGE
│
└───────────────► BUILD
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
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)