Think of software architecture like building architecture for a house:
Building a House Analogy:
- Foundation: Do you need a basement? Concrete slab? This is like choosing your database
- Structure: Wood frame vs. steel vs. brick? One story vs. two stories? This is like choosing monolith vs. microservices
- Layout: Open floor plan vs. separate rooms? This is like how you organize your code
- Utilities: Where do pipes and wires go? This is like how data flows through your system
- Materials: Cheap but quick vs. expensive but durable? This is like choosing programming languages and tools
Just like you can't easily move walls after a house is built, changing fundamental software architecture decisions later is expensive and painful. Good architecture decisions upfront save massive headaches later.
This is THE fundamental choice in software architecture.
MONOLITH:
Think: One big building where everything happens under one roof
How it works:
- All your code lives together in one place
- When one part needs to talk to another, it just calls a function
- You deploy everything as a single unit
- If you need more power, you scale the whole thing up
Pros:
- Simpler to start with
- Easier to test (everything's in one place)
- Faster communication between parts (no network delays)
- One deployment process to master
Cons:
- As it grows, becomes harder to understand
- Can't scale individual pieces independently
- One bug can crash everything
- Hard for multiple teams to work without stepping on each other's toes
Example from real life: A small restaurant where one kitchen makes everything - appetizers, main courses, desserts, drinks. It's simple when you're small, but imagine trying to scale to 100 locations with that model.
MICROSERVICES:
Think: A shopping mall with many specialized stores
How it works:
- Your code is split into many small, independent services
- Each service does ONE thing (user accounts, payments, search, etc.)
- Services talk to each other over the network (like HTTP)
- You can deploy and scale each service independently
Pros:
- Can scale the parts that need it (scale payment service during Black Friday, leave blog service alone)
- Different teams can own different services without coordination
- One service crashing doesn't kill everything
- Can use different languages/tools for different services
Cons:
- WAY more complex to set up and manage
- Network communication is slower than in-memory
- Debugging is harder (error could be in any of 50 services)
- Need sophisticated tooling to manage it all
Example from real life: Amazon - they have separate teams/services for search, recommendations, cart, payments, shipping, reviews. Each can scale independently. During holiday shopping, they scale up payment and shipping services massively while leaving their book recommendation service at normal capacity.
KEY INSIGHT: Most startups should start with a monolith. It's not that monoliths are bad - they're simpler and perfectly adequate for most small-to-medium businesses. Only move to microservices when you have a specific reason:
The Trap: "Distributed Monolith"
The worst of both worlds:
Multiple services that aren't truly independent
Problems:
- Service A can't deploy without coordinating with Service B
- Changes ripple across multiple services
- All the complexity of microservices, none of the benefits
How it happens:
- Breaking up a monolith without thinking through data dependencies
- Services sharing databases
- Poor API design that tightly couples services
It's like splitting your restaurant into multiple kitchens but they all still need to share the same stove - you just added complexity without gaining independence.
This is about organizing code around business concepts rather than technical concepts.
BAD (Technical Organization):
- controllers/
- services/
- models/
- utilities/
Problem: Where do I find "everything about customers"?
It's scattered across all folders!
GOOD (Domain-Driven):
- customers/
- CustomerService
- CustomerModel
- CustomerController
- orders/
- payments/
- shipping/
Better: Everything about customers is in one place
The "Ubiquitous Language" Principle:
Everyone should use the same words:
- Engineering: "user account"
- Marketing: "user account"
- Product: "user account"
- Database: "user_accounts" table
NOT:
- Engineering: "entity"
- Marketing: "subscriber"
- Product: "member"
- Database: "usr_data" table
Real-world example: An e-commerce company should have clear concepts that everyone uses:
- Order (not "transaction" in one place, "purchase" in another)
- Cart (not "basket" sometimes, "shopping bag" other times)
- Customer vs Guest (clear distinction)
The code should mirror how the business talks. If a product manager says "When a customer adds an item to their cart," the code should literally have:
customer.cart.addItem(item)
Not some cryptic technical abstraction that requires a decoder ring.
Think of APIs like contracts between services - literal agreements on how they'll communicate.
THE RESTAURANT ANALOGY:
Kitchen (backend API) ← Menu (API contract) → Customer (frontend)
The menu is a contract:
- Customer orders "Burger, no pickles"
- Kitchen knows exactly what that means
- If kitchen changes recipe, menu doesn't need to change
- If menu changes prices, kitchen doesn't need to change
BAD API: Kitchen yells "WHAT DO YOU WANT?" and customer yells back random food descriptions
GOOD API: Clear menu with predictable format
Types of APIs (from simplest to most complex):
REST (Most Common):
Uses familiar web concepts:
- GET /users/123 → Fetch user #123
- POST /users → Create new user
- PUT /users/123 → Update user #123
- DELETE /users/123 → Delete user #123
Pros: Simple, everyone knows it
Cons: Can require multiple requests to get related data
GraphQL (More Sophisticated):
Client asks for exactly what it needs:
Query:
{
user(id: 123) {
name
email
orders {
total
items {
name
}
}
}
}
Gets back exactly that structure.
Pros: One request gets everything, self-documenting
Cons: More complex to set up, less standardized
Queues & Pub/Sub (Asynchronous):
Instead of "Call me and wait for answer"
It's "Leave a message, I'll get to it"
Example: Order processing
1. Customer places order → Message goes in queue
2. Payment service processes when ready
3. Shipping service processes when ready
4. Email service sends confirmation when ready
Pros: Services don't block each other
Cons: More complex, harder to debug
BEST ADVICE: Start with simple REST unless you have a compelling reason not to. Don't prematurely optimize to fancy patterns.
This is where we gets specific about what makes a good vs bad API.
IDEMPOTENCY (A critical concept):
Non-Idempotent (Dangerous):
POST /create-order
{
"product": "laptop",
"price": 1000
}
Problem: If this request happens twice (network hiccup, user clicks twice),
you charge the customer $2000 for two laptops they didn't want!
Idempotent (Safe):
POST /create-order
{
"idempotency_key": "uuid-12345",
"product": "laptop",
"price": 1000
}
Server remembers uuid-12345. If request comes again, returns same response,
doesn't create second order.
Real-world importance:
- Stripe (payment processor) uses idempotency keys religiously
- Without them, network glitches could charge customers multiple times
- With them, you can safely retry failed requests
VERSIONING:
Bad: Change API and break all existing apps
Good: Support multiple versions
- v1/users (old apps)
- v2/users (new apps)
Gradually migrate, eventually deprecate v1
Use as few languages as possible. Every additional language multiplies your problems:
ONE LANGUAGE:
- One build system to maintain
- One set of libraries to understand
- Engineers can work across all projects
- One set of security vulnerabilities to track
MULTIPLE LANGUAGES:
- JavaScript build tools (npm, webpack)
- Python build tools (pip, virtualenv)
- Go build tools (go modules)
- Can't share code between projects
- Need engineers who know each language
- Security scans for each ecosystem
When to add another language:
-
Must-have library only exists in that language
- Example: Certain ML libraries only in Python
-
Performance absolutely requires it
- Example: Real-time video processing might need C++
-
Team expertise
- Example: You hire 3 engineers who are Ruby experts but your stack is Python
BEST ADVICE: Your default answer should be "no" to new languages. Make people build a bulletproof case. The operational burden usually outweighs the benefits.
This is often overlooked but critical.
TRANSACTIONAL DATA:
What: The data your app needs RIGHT NOW
Example: User's current shopping cart, account balance
Requirements:
- FAST (milliseconds)
- RELIABLE (can't lose data)
- Modest size
Tools: PostgreSQL, MySQL, MongoDB
ANALYTICAL DATA (Business Intelligence):
What: Historical data for business insights
Example: "How many users signed up last month?"
Requirements:
- Can be slower (seconds/minutes okay)
- LARGE amounts of data
- Complex queries
Tools: Snowflake, BigQuery, Redshift
BEHAVIORAL DATA (Analytics):
What: What users actually DO in your app
Example: "User clicked 'Add to Cart', scrolled to bottom, left page"
Requirements:
- HUGE volume
- Some data loss acceptable (ad blockers, etc.)
- Need visualization tools
Tools: Segment (collection), Mixpanel/Amplitude (analysis)
THE MISTAKE BEGINNERS MAKE:
Trying to use one database for all three:
Bad: Run analytics queries on your production database
Result: Your website goes down because an analyst ran a huge report
Good: Copy data to separate analytics database nightly
Result: Analysts can't crash production, queries run faster
OBJECT-ORIENTED PROGRAMMING (OOP):
Organize code like real-world objects
Example:
class Customer {
name: string
email: string
placeOrder(items) {
// logic here
}
}
const customer = new Customer()
customer.placeOrder(items)
Pros: Mirrors business concepts, intuitive
Cons: Can get tangled, inheritance hierarchies get messy
FUNCTIONAL PROGRAMMING:
Organize code as pure functions (same input → same output)
Example:
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0)
}
const total = calculateTotal(cartItems)
Pros: Easier to test, less surprising behavior
Cons: Can be verbose, learning curve
Takeaway: Don't be religious about it. Most real-world code is a pragmatic mix. Use objects for domain concepts (Customer, Order) and pure functions for calculations and transformations.
THE TESTING PYRAMID:
/\
/ \ E2E Tests (few, slow, brittle)
/ \
/------\ Integration Tests (moderate)
/--------\
/----------\ Unit Tests (many, fast, coupled)
/__________\
Let's break each down:
UNIT TESTS (Test smallest pieces):
Pros:
- Fast (milliseconds)
- Easy to write
Cons:
- Tightly coupled to implementation
- Refactoring breaks them constantly
- Lots of mock/stub code to maintain
Takeaway: Useful but often overemphasized
INTEGRATION TESTS (Test APIs):
Pros:
- Test real behavior
- Survive refactoring better
- Less mock code
Cons:
- Slower
- Need test databases/services
Takeaway: Often the sweet spot for startups
END-TO-END TESTS (Test like a real user):
Pros:
- Catch real user-facing bugs
- Test everything together
Cons:
- SLOW (minutes per test)
- FLAKY (race conditions, timing issues)
- Expensive to maintain
Takeaway: Use sparingly for critical flows only
BEST ADVICE:
Test the public interface (what users/services actually interact with), not internal implementation:
Bad:
- Test individual functions
- Test internal data structures
- Tests break every refactor
Good:
- Test API endpoints
- Test user workflows
- Tests survive refactoring
This is a game-changer that is really important.
THE OLD WAY ("ClickOps"):
1. Log into AWS console
2. Click "Create Database"
3. Fill out form with settings
4. Click through 10 screens
5. Hope you remember all settings
6. Try to recreate in staging environment
7. Forget one setting
8. Things break mysteriously
THE NEW WAY (Infrastructure as Code - Terraform):
database.tf:
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = "db.t3.micro"
username = "admin"
database_name = "myapp"
backup_retention_period = 7
multi_az = true
}
Benefits:
- Same infrastructure every time (dev, staging, prod)
- Version controlled (see what changed)
- Documented (code IS documentation)
- Automated (no manual clicking)
REAL-WORLD STORY:
Company has database running production. Engineer leaves. No one knows the settings. Need to create staging environment. Spend 2 days trying to figure out production settings.
With IaC: It's literally written in code. Takes 5 minutes.
This is one of the most important modern innovations.
THE PROBLEM CONTAINERS SOLVE:
Engineer 1: "Works on my machine!"
Engineer 2: "Doesn't work on mine..."
Reasons:
- Engineer 1 has Python 3.9, Engineer 2 has Python 3.11
- Engineer 1 has library X installed, Engineer 2 doesn't
- Engineer 1 on Mac, Engineer 2 on Windows
THE CONTAINER SOLUTION:
Dockerfile (recipe):
FROM python:3.9
COPY app.py /app/
RUN pip install flask==2.0.1
CMD ["python", "/app/app.py"]
Result: An "image" (like a snapshot) that runs IDENTICALLY on:
- Developer laptop
- Test server
- Production server
- Any cloud provider
SHIPPING CONTAINER ANALOGY:
Before standard shipping containers:
- Every port needed different equipment
- Loading/unloading was chaotic
- Different packaging for everything
After standard shipping containers:
- Same container fits on any ship, train, truck
- Standardized cranes work everywhere
- Revolutionized global trade
Docker did the same for software.
THE PRAGMATIC APPROACH:
Most startups should focus on preventing human error, not sophisticated attacks:
MORE LIKELY:
- Engineer forgets to add authentication to API endpoint
- Password stored in code (accidentally committed to GitHub)
- Laptop stolen with company data
- Employee falls for phishing email
LESS LIKELY (at small startups):
- Nation-state actor targeting you specifically
- Zero-day exploit in your cloud provider
- Advanced persistent threat
PRACTICAL SECURITY WINS:
1. Never Build Your Own Authentication:
Bad: Spend 3 months building login system
Risk: Get password hashing wrong, data breach
Good: Use Auth0, Firebase Auth, AWS Cognito
Result: Secure by default, 2FA built-in, maintained by experts
2. Don't Commit Secrets:
Bad:
config.js:
const API_KEY = "sk_live_12345..." ← pushed to GitHub
Good:
config.js:
const API_KEY = process.env.API_KEY ← loaded from secret manager
3. Default to Private:
Set company defaults:
- Google Drive: Default sharing = "Company Only"
- Slack: External sharing = Off
- Databases: No public internet access
4. Use SSO (Single Sign-On):
Instead of:
- 50 different passwords
- Engineers reusing passwords
- Can't disable access when someone leaves
Use SSO:
- One login for everything
- Enforce 2FA universally
- Disable one account, disables everything
This deserves its own section because it's so counter to startup culture.
THE TEMPTATION:
"Let's use [NEW SHINY THING]! It's the future!"
Examples:
- New JavaScript framework that just launched
- Bleeding-edge database with cool features
- Novel architectural pattern from tech blog
THE HIDDEN COSTS:
Documentation:
- Incomplete or wrong
- Few Stack Overflow answers
- Spend days debugging basic issues
Ecosystem:
- Missing tools you need
- Poor IDE integration
- No established best practices
Team:
- Time spent learning
- Harder to hire (fewer people know it)
- Knowledge loss when person leaves
Maintenance:
- More bugs (immature software)
- Breaking changes between versions
- May get abandoned by creators
WHEN TO USE NEW TECH:
Only when you have a compelling, specific reason:
Good reasons:
- Solves a problem impossible with boring tech
- Orders of magnitude better performance (not 2x, like 10x+)
- Team has deep expertise already
- Your competitive advantage depends on it
Bad reasons:
- "It's cool"
- "Everyone's talking about it"
- "Looks good on resume"
- "Old tech feels boring"
REAL WORLD EXAMPLE:
Stripe migrated millions of lines from Flow to TypeScript (18-month project).
Why? Not because TypeScript was "cool":
- Flow was causing crashes
- Locking up laptops
- IDE integration was broken
- TypeScript had solved these problems
The pain justified the massive cost.
If you're starting a new project, here's a highly recommended approach:
Phase 1: Just Starting (0-5 engineers)
- ✅ Monolith (not microservices)
- ✅ One programming language
- ✅ Boring, proven technologies
- ✅ Hosted services (not self-managed)
- ✅ PostgreSQL or MySQL (not exotic databases)
- ✅ Simple REST APIs
- ✅ Basic CI/CD pipeline
- ✅ Auth0 or similar for login
- ✅ Infrastructure as Code from day one
Phase 2: Growing (5-20 engineers)
- ✅ Still monolith (probably)
- ✅ Domain-driven design becomes critical
- ✅ Feature branch environments
- ✅ Continuous deployment
- ✅ Basic monitoring (APM)
- ✅ May split out 1-2 services for specific needs
- ✅ Analytics pipeline to data warehouse
Phase 3: Scaling (20+ engineers)
- ✅ Consider microservices for good reasons
- ✅ Dedicated DevOps/Platform team
- ✅ Service mesh if many services
- ✅ Sophisticated monitoring & alerting
- ✅ Multiple data pipelines
- ✅ Potentially multiple programming languages
-
Start simple, complexify only when necessary
- Monolith → Microservices (not the reverse)
- Boring tech → Shiny tech (only with good reason)
-
Optimize for change, not perfection
- You can't predict the future
- Make it easy to refactor
- Test the interface, not internals
-
Business logic belongs in the backend
- Don't trust the client
- Backend is easier to test
- One source of truth
-
Reproducibility prevents disasters
- Infrastructure as Code
- Containers
- Automated deployments
-
Documentation is architecture
- Code should be self-documenting
- APIs need clear contracts
- Ubiquitous language across company
-
Security is everyone's job
- Don't build auth yourself
- Never commit secrets
- Default to private/secure

