Microservices Fundamentals

Microservices are not a silver bullet and they are not a fashion. They are a specific trade: you give up the simplicity of one process for the freedom to ship, scale, and rewrite parts of your system independently. This tutorial is the honest map of that trade.

Easy30 min read

Why Microservices Matter

Why Microservices Matter

The Problem: A successful product grows. The codebase grows with it. Eventually a single deploy ships the work of fifty engineers, a one-line bug in checkout means the whole site rolls back, and the team that needs more CPU for video transcoding is stuck waiting for the team that wants to refactor the email templates.

The Solution: Split the system into small, independently deployable services that each own one capability. Teams ship at their own cadence. The transcoder scales without dragging the email templates along. A bug in one service does not roll back the others.

Real Impact: Amazon went from one deploy every few weeks to thousands of deploys per day. Netflix runs hundreds of services and a bad recommendation does not stop you watching. Uber runs over 2,000 services to keep dispatch decisions under a second across 10,000 cities.

Real-World Analogy

Think of a restaurant kitchen. The grill, the salad station, the dessert station, the bar — each has its own equipment, its own ingredients, its own chef who knows the craft. When the grill breaks, the kitchen still serves salads, drinks, and desserts. When the dessert chef quits, you replace one person, not the whole brigade.

A monolith is one chef trying to do every station from one workbench. It works fine for a small cafe. It fails as soon as you have more than one customer per minute or more than one chef who wants to work in the kitchen.

Most engineers meet microservices through hype. The clean version of the story is simpler: microservices are a way of cutting an application along team and capability lines so that the cost of growing the system stays roughly linear in the number of teams, instead of growing exponentially. Whether they are right for your system depends entirely on whether you have that growth problem or are merely anticipating it.

The Reframe That Helps

A microservice is not a small program. It is an independently deployable unit owned by one team. The size question (“how small is small?”) is the wrong question. The right question is: can one team ship this on its own schedule, run it on its own infrastructure, and rewrite it without coordinating with anyone else? If yes, it is a microservice. If no, it is something else — usually a distributed monolith pretending to be microservices.

Monolith vs Microservices

The honest comparison starts with admitting that monoliths are not bad. They are the right shape for most applications most of the time. Microservices are what you reach for when a monolith stops fitting — not before.

The shape of a monolith

ecommerce-monolith/
├── app.py                  # all logic in one process
├── models/
│   ├── user.py
│   ├── product.py
│   ├── order.py
│   └── payment.py
├── database/
│   └── single_database.db  # one schema for everything
└── templates/

One process. One deploy. One database. One language. One build. That is also one set of eyes on every change, one rollback for every bug, and one team holding the line on every refactor.

The shape of a microservice mesh

ecommerce/
├── user-service/        # owns users + sessions + auth
│   └── postgres
├── product-service/     # owns the catalog
│   └── postgres
├── cart-service/        # owns shopping carts
│   └── redis
├── order-service/       # owns orders + workflow
│   └── postgres
├── payment-service/     # owns charges + refunds
│   └── postgres
├── shipping-service/    # owns rates + tracking
│   └── postgres
└── notification-service/  # owns email + SMS + push
    └── kafka topic

Each service owns its data. Each speaks to others over the network. Each can be written in a different language, deployed at a different cadence, and scaled to a different size. That is the upside. The downside is that you now have a network in the middle of every former function call.

Side by side

AspectMonolithSOAMicroservices
SizeOne large applicationA few large servicesMany small services
DeploymentDeploy the whole appDeploy service groupsDeploy each service independently
ScalingScale the whole appScale service groupsScale per service
Tech stackOne stack everywhereUsually one stackPolyglot — pick per service
DatabaseOne shared schemaOften sharedOne per service
CommunicationIn-process function callsEnterprise Service BusHTTP, gRPC, async messaging
Team shapeOne large teamTeams per service groupSmall autonomous teams
Failure blast radiusWhole system downWhole subsystem downIsolated to one service (in theory)
Time to first deployHoursDaysWeeks — you have to build infra first
Operational costLowModerateHigh — you are running a distributed system
TestingTest the whole appIntegration-heavyPer-service + contract + end-to-end
Best fitSmall apps, MVPs, single teamEnterprise integrationMany teams, large scale, cloud-native

The pattern across the rows

Microservices trade local simplicity (one process, one DB, one deploy) for organizational simplicity (one team, one schedule, one rewrite). If your bottleneck is local — you only have a few engineers and one product — you trade the wrong way and end up paying the operational cost of a distributed system to solve a problem you do not have.

The Defining Characteristics

Strip away the marketing and a microservice has four traits that make it a microservice. Miss any one of them and you have a different animal — usually a distributed monolith.

1. Single, well-defined responsibility

A microservice does one thing. The Payment service charges cards. The Catalog service answers questions about products. The Notification service sends email and SMS. If you cannot describe what a service does in one sentence without using the word “and,” the boundary is wrong.

2. Independently deployable

You can ship the Payment service at 2 PM on a Tuesday without coordinating with anyone. The Catalog team can roll back without affecting checkout. If a deploy of one service requires a deploy of another, they are one service wearing two costumes.

3. Owns its data

The Payment service is the only thing that talks to the payment database. The Catalog service is the only thing that talks to the catalog database. Other services ask over the network — they do not reach into the database directly. The moment two services share a schema you have lost your independence and gained a coordination problem with extra latency.

4. Loosely coupled, communicates over the network

Services talk through APIs (HTTP, gRPC) or messages (Kafka, SQS). They do not link in each other’s code. The contract between them is the API, not the implementation. You can rewrite the Catalog service in Go on Monday and as long as the API still answers the same way, every other service is unaffected.

The most common failure mode

The pattern most teams ship and call “microservices”: many small services, but they all share one database. That is a distributed monolith. You pay the operational cost of microservices — networks, deploys, observability — and get none of the independence, because every schema migration still needs every team to coordinate. If services share a database, you have a monolith with extra latency.

A simple service in three languages

The shape is the same in every stack: an HTTP handler, a model, and one job done well. The choice of language is part of what you get to decide per service.

# Python (Flask) — a Product service
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/products/<int:product_id>")
def get_product(product_id):
    product = {"id": product_id, "name": "Laptop", "price": 999.99}
    return jsonify(product)

if __name__ == "__main__":
    app.run(port=5001)
// Java (Spring Boot) — same Product service
@RestController
@RequestMapping("/products")
public class ProductController {

    @Autowired private ProductService productService;

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable Long id) {
        return ResponseEntity.ok(productService.findById(id));
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.save(product));
    }
}
// Go — a Cart service
package main

import (
    "encoding/json"
    "net/http"
    "github.com/gorilla/mux"
)

type CartItem struct {
    ProductID string  `json:"product_id"`
    Quantity  int     `json:"quantity"`
    Price     float64 `json:"price"`
}

type Cart struct {
    UserID string     `json:"user_id"`
    Items  []CartItem `json:"items"`
}

func getCart(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    cart := Cart{
        UserID: vars["userID"],
        Items:  []CartItem{{ProductID: "123", Quantity: 2, Price: 29.99}},
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(cart)
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/cart/{userID}", getCart).Methods("GET")
    http.ListenAndServe(":5003", nil)
}

When Microservices Are the Right Choice

The honest checklist. If you can answer yes to most of these, microservices will probably make your life better. If you cannot, they almost certainly will not.

SignalWhat to look forWhy it matters
Team size10+ engineers across multiple teamsBelow this, the coordination cost of microservices is higher than the coordination cost of a monolith.
Deployment frequencyYou want to deploy multiple times a dayOne deploy pipeline gating fifty engineers stops working at this cadence.
Differential scalingOne feature needs 10x the capacity of othersImage processing vs auth: you should not pay to scale auth to handle image traffic.
Tech diversity mattersML in Python, real-time in Go, finance in JavaA monolith forces everything into one runtime.
Domain has clear boundariesE-commerce, fintech, marketplaces — capabilities are obviousYou cannot draw service lines you cannot draw on a whiteboard first.
DevOps maturity existsCI/CD, monitoring, IaC are already in placeMicroservices make every operational gap more painful, not less.
Teams are autonomousTeams can decide what to ship and whenIndependent deploys require independent decision-making.
You can debug distributed systemsTracing, structured logging, runbooks are normal hereDistributed bugs are real bugs and require real tooling.

The one-line test

If your hardest bottleneck this quarter is “we cannot deploy fast enough because too many teams share one pipeline,” microservices help. If it is “we cannot ship features fast enough because we are still figuring out the product,” they hurt.

When Microservices Are the WRONG Choice

Do not start a project as microservices

Both Amazon and Netflix started as monoliths and migrated to microservices only when scale and team size demanded it. Starting with microservices on day one is a confident statement that you know exactly what your service boundaries are — before you have shipped a single thing to a real user. You almost certainly do not. Service boundaries you draw before discovery will be wrong, and they will be expensive to redraw.

  • Team size: Fewer than 5 engineers? Stay monolithic. The whole team can read the whole codebase.
  • Traffic: Under 1,000 requests per second? A boring monolith will handle it on one box with room to spare.
  • Org maturity: No CI/CD, no on-call rotation, no monitoring? Fix that first — microservices will magnify every gap.
  • Product market fit: Still iterating on what the product even is? You will move faster as a monolith and rewrite cheaper, too.

The cost the marketing leaves out

Every microservice you ship adds a recurring tax: a deploy pipeline, a service registry entry, a dashboard, alerts, on-call ownership, an SLO, contract tests, runbooks. Twenty services is twenty of each of those. The cost is not in writing the services. It is in operating them, every day, forever.

The pre-flight check

Before you commit to the migration, write down honest answers to these:

Building Blocks of a Microservice

A production microservices architecture is not just “some services.” It is the services plus the supporting infrastructure that lets them find each other, route traffic safely, and stay alive when something goes wrong.

API Gateway

A single entry point for outside traffic. The gateway handles routing, authentication, rate limiting, request shaping, and translation between protocols (REST in, gRPC out). Without a gateway, every client has to know about every service. With one, the topology behind it can change without breaking clients.

Client → API Gateway → [Auth Service, User Service, Product Service, ...]

Cross-cutting concerns the gateway owns:
  authentication and token verification
  rate limiting and quota enforcement
  request logging and tracing context
  protocol translation (REST <-> gRPC)
  response caching for safe GETs

Service discovery

In a static world, services know each other’s addresses. In a real world — pods come and go, deploys roll out, instances scale up and down — services need to discover where their dependencies live right now.

Configuration server

Centralized configuration so the same service binary runs in dev, staging, and prod — the difference is in the config it reads at startup. Spring Cloud Config, Consul KV, AWS Parameter Store, etcd. The point is to keep secrets and environment-specific values out of the image.

Message queue / event bus

Asynchronous communication for everything that does not need an immediate answer. RabbitMQ for reliable work queues. Kafka for high-throughput event streams and replay. SQS for simple AWS-native queueing. Async messaging is what lets the Order service confirm an order without waiting for the Notification service to send the email.

Circuit breaker and resilience

The pattern that keeps a slow downstream from cascading into a system-wide outage. Each service wraps its dependencies in a breaker that fails fast when the downstream is sick. This is the topic of its own tutorial — for now, know that without it, microservices fail in worse ways than monoliths, not better ones.

Observability stack

Three pillars: logs (what happened), metrics (how often / how fast), traces (the path a single request took across services). Without all three, the moment you have more than five services you cannot reason about what is happening in production.

Communication, Data, and Deployment Realities

Synchronous calls between services

The simplest case: one service makes an HTTP call to another and waits for the answer. Easy to write, easy to trace, easy to misuse — the moment you build a chain of three or four synchronous hops, your tail latency is the sum of all of them and one slow link slows the whole chain.

// Node.js — Order Service calls Payment Service
const express = require("express");
const axios   = require("axios");

app.post("/orders", async (req, res) => {
    const order = req.body;
    try {
        const payment = await axios.post(
            "http://payment-service:5002/payments",
            { orderId: order.id, amount: order.total },
        );
        if (payment.data.status === "success") {
            await saveOrder(order);
            res.json({ status: "confirmed" });
        }
    } catch (err) {
        res.status(500).json({ error: "payment failed" });
    }
});

A more honest production-shaped service

# Python (FastAPI) — Order service that talks to two dependencies
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import httpx

app = FastAPI(title="Order Service", version="1.0.0")

class OrderItem(BaseModel):
    product_id: int
    quantity: int
    price: float

class Order(BaseModel):
    order_id: Optional[int] = None
    customer_id: int
    items: List[OrderItem]
    total_amount: float
    status: str = "pending"
    created_at: Optional[datetime] = None

async def verify_inventory(product_id: int, quantity: int) -> bool:
    async with httpx.AsyncClient() as client:
        r = await client.post(
            "http://inventory-service:8001/verify",
            json={"product_id": product_id, "quantity": quantity},
        )
        return r.json()["available"]

async def process_payment(amount: float, customer_id: int) -> bool:
    async with httpx.AsyncClient() as client:
        r = await client.post(
            "http://payment-service:8002/charge",
            json={"amount": amount, "customer_id": customer_id},
        )
        return r.status_code == 200

@app.post("/orders", response_model=Order, status_code=201)
async def create_order(order: Order):
    for item in order.items:
        if not await verify_inventory(item.product_id, item.quantity):
            raise HTTPException(400, f"product {item.product_id} not available")

    if not await process_payment(order.total_amount, order.customer_id):
        raise HTTPException(402, "payment failed")

    order.order_id   = save_to_database(order)
    order.status     = "confirmed"
    order.created_at = datetime.now()
    return order

Asynchronous, fire-and-forget

// Spring Boot — User service notifies asynchronously
@Service
public class UserService {

    @Autowired private UserRepository userRepository;
    @Autowired private PasswordEncoder passwordEncoder;
    @Autowired private RestTemplate restTemplate;

    @Transactional
    public User createUser(UserDTO dto) {
        if (userRepository.existsByEmail(dto.getEmail())) {
            throw new DuplicateEmailException("email exists");
        }
        User user = new User();
        user.setEmail(dto.getEmail());
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        user.setCreatedAt(LocalDateTime.now());
        User saved = userRepository.save(user);

        // Non-blocking: the user is created even if notification fails
        CompletableFuture.runAsync(() -> {
            try {
                restTemplate.postForEntity(
                    "http://notification-service:8003/notifications",
                    welcomeEmail(saved),
                    Void.class);
            } catch (Exception e) {
                logger.error("notification failed", e);
            }
        });
        return saved;
    }
}

Synchronous vs asynchronous — the rule of thumb

  • Use sync when the caller cannot continue without the answer (charge a card, check inventory before confirming).
  • Use async when the caller does not care about the answer right now (send the welcome email, update the search index, fan out an “order placed” event).
  • If a chain of three sync calls would all be “the user does not need this right now,” the chain should be async.

Data ownership and the shared-DB anti-pattern

Each service owns the database that backs it. Other services do not query that database directly — they ask over the network. This is the rule that protects independence: if I can change my schema without telling you, then we are independent. If I have to send my migration to your team, we are not.

Anti-pattern: the shared schema

Sharing one database across services is the single fastest way to lose every benefit of microservices while keeping every cost. Schema migrations now require coordination across teams. One bad query starves everyone. The blast radius of a corrupt write is global. If two services need the same data, the right answer is for one to own it and the other to ask.

Containerized deployment

# Dockerfile for the Product service
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5001
CMD ["python", "product_service.py"]
# docker-compose.yml — bringing the mesh up locally
version: "3.8"
services:
  product-service:
    build: ./product-service
    ports: ["5001:5001"]
    environment:
      DATABASE_URL: postgresql://db:5432/products

  order-service:
    build: ./order-service
    ports: ["5002:5002"]
    environment:
      DATABASE_URL: postgresql://db:5432/orders
      PRODUCT_SERVICE_URL: http://product-service:5001

  user-service:
    build: ./user-service
    ports: ["5003:5003"]
    environment:
      DATABASE_URL: postgresql://db:5432/users

  db:
    image: postgres:13
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Scaling Considerations

The promise of microservices is “scale only the part that needs it.” The reality is more nuanced — you can do this, but you also have to scale the things around the services.

Per-service scaling: where the win is

The Search service in an e-commerce site might handle 50x the traffic of the Returns service. In a monolith, both scale together — you pay for Returns capacity to handle Search load. In microservices, each scales on its own metrics: Search scales on QPS, Returns scales on queue depth, image processing scales on CPU.

ServiceTypical scaling signalTypical shape
API gateway / catalogRequests per secondMany small stateless replicas behind a load balancer
Image / video processingCPU + queue depthA pool of workers consuming a job queue
Recommendations / searchP99 latency, cache hit rateMemory-heavy boxes, often with local indexes
Payments / ordersModest QPS, but transactionalFewer replicas, careful with DB connections
NotificationsQueue depthAsync workers; horizontal-only

Granularity: too small, just right, too large

The right size for a service is the size where one team can own it end to end without becoming a bottleneck and without becoming bored. Too small and you turn local function calls into network hops for no reason. Too large and you are back to a monolith with extra YAML.

Too smallJust rightToo large
Excessive network calls for one user actionClear single purpose, named in one phraseMultiple unrelated responsibilities
Hard to follow what happens during a requestOne team can own and operate itMultiple teams need to coordinate to ship
Deploy and observability overhead per changeIndependently deployable on its own scheduleCannot deploy a part without redeploying the whole
Latency budget eaten by hopsLatency budget realistic for its jobInternal coupling that should have been a network seam

Conway’s Law: the architecture follows the org chart

“Organizations design systems that mirror their communication structures.” — Melvin Conway

This is not a slogan. It is an observation. If two teams have to talk constantly to ship anything, they will end up owning one service together — or two services that are coupled enough that they may as well be one. The corollary: pick service boundaries that follow your team boundaries, or change one until they match.

Bounded contexts: the same word means different things

From Domain-Driven Design: a bounded context is the slice of the business where one model holds. The word “Customer” means different things in different contexts — and that is fine. Each service models the customer in the way that fits its job:

# Sales context — Customer as buyer
class Customer:
    customer_id: str
    credit_limit: Decimal
    purchase_history: List[Order]
    loyalty_points: int

# Support context — Customer as case
class Customer:
    customer_id: str
    support_tier: str           # bronze, silver, gold
    open_tickets: List[Ticket]
    satisfaction_score: float

# Shipping context — Customer as recipient
class Customer:
    customer_id: str
    shipping_addresses: List[Address]
    delivery_preferences: dict

One id, three models. Each owned by the team that uses it. None reaches into the other.

Real-World Examples

The big consumer companies are the ones whose engineering posts everyone has read. Their architectures are interesting not because they are typical — they are not — but because they show what microservices look like at the scale where the trade actually pays off.

Amazon: the two-pizza team

Amazon famously structures around the “two-pizza team” rule: if a team needs more than two pizzas to feed, it is too large. Each team owns a service end to end — build, deploy, operate, on-call. This is the org-chart side of microservices made explicit.

Netflix: from monolith to hundreds of services

Netflix evolved from a DVD-shipping monolith into a cloud-native streaming platform with hundreds of microservices. They open-sourced Eureka (service discovery), Hystrix (circuit breakers), Zuul (API gateway), and the Simian Army (chaos engineering). The pattern of “everything fails, design for it” is largely Netflix’s contribution to industry practice.

Uber: 2,200 services and dispatch in under a second

Uber operates one of the largest microservice deployments in the world to handle 100M+ trips per day across 10,000+ cities.

ServicePurposeScale signal
Dispatch ServiceMatch riders with driversReal-time geospatial calculations
Surge PricingDynamic pricing on demandUpdates every few seconds per region
Maps ServiceRouting and navigationBillions of map tiles
Payment ServiceCharge and pay outMulti-currency, multi-region
Driver ServiceManage driver availabilityReal-time location tracking

To make this work, Uber built and open-sourced a stack of supporting tools: TChannel (RPC), Ringpop (distributed hash ring for routing), Cadence (workflow orchestration), Jaeger (distributed tracing), and M3 (metrics handling 500M+ data points per second). The same codebase serves Uber, Uber Eats, and Uber Freight.

The honest takeaway: this scale of microservices requires this scale of investment in tooling. Most companies should not try to copy Uber’s architecture — they should copy Uber’s rigour about when to add a new service.

Spotify: squads, tribes, and service ownership

Spotify made the org-chart side of microservices explicit with the “squad” model: small cross-functional teams, each owning a slice of the product end to end. Squads are grouped into “tribes” aligned with a major capability. The architecture follows the org — each squad ships and runs its own services on its own cadence.

The pattern across all four

Best Practices

The short list

  • Start with a modular monolith. Get the boundaries right in code first — modules with clear interfaces — before you make them network seams. It is much cheaper to refactor module boundaries than service boundaries.
  • One team per service, one service per team. If two teams own one service or one team owns five services, the org and the architecture are fighting each other.
  • Each service owns its data. No shared schemas, no cross-service joins. If two services need the same data, one owns it and the other asks.
  • Services talk only over the wire. No shared libraries that hide inter-service coupling. The contract is the API; the implementation is private.
  • Build the platform before the second service. CI/CD, container registry, service discovery, observability, secret store, on-call rotation. The first service feels like overkill. The tenth service feels like home.
  • Async by default for non-critical paths. Welcome emails, search index updates, audit log writes — none of these should block the user’s response.
  • Pair every breaker with a fallback, every retry with a budget, every timeout with a metric. Resilience patterns are part of every service, not an afterthought.
  • Treat operations as a first-class deliverable. Runbooks, dashboards, alerts, and on-call rotations are part of “done.”

The migration path, if you are not there yet

If your honest answer to most of the readiness questions is “not yet” but you believe microservices are in your future, do this in order:

  1. Build a modular monolith. Strict module boundaries, internal APIs between modules, separate schemas inside one database. This is the single most useful first step and most teams underrate it.
  2. Build the DevOps foundation. CI/CD, infrastructure as code, structured logging, metrics, alerting, on-call. None of this requires microservices, all of it makes microservices possible.
  3. Grow the team. Microservices solve a coordination problem you do not have until you have multiple teams.
  4. Extract the first service. Pick a bounded context that is on the edge — not the heart of the business. Notification, search, image processing. Not checkout.
  5. Run the monolith and the service side by side. Use the Strangler Fig pattern: route a percentage of traffic to the new service, watch every metric, increase the percentage as confidence grows.
  6. Iterate. Each new service is cheaper than the last because the platform you built for the first one is now reused. After three or four extractions you have a real microservices practice.

The Strangler Fig in practice

# Routing layer — gradually move /users from monolith to new service
from flask import Flask, request, redirect
import requests

app = Flask(__name__)

@app.route("/users/<path:path>", methods=["GET", "POST", "PUT", "DELETE"])
def user_proxy(path):
    if is_feature_enabled("new_user_service"):
        r = requests.request(
            method=request.method,
            url=f"http://user-microservice:5000/{path}",
            headers=request.headers,
            data=request.get_data(),
        )
        return (r.content, r.status_code)
    return redirect(f"http://monolith:8000/users/{path}")

The fig grows around the tree. The tree dies. The fig is left standing. That is the migration in one sentence.

The single most useful sentence about microservices

Microservices are an organizational pattern that happens to be implemented in software. The hardest problems are not technical — they are about teams, ownership, and decision-making. If your teams cannot ship independently today as a monolith, splitting the codebase will not make them able to. Fix the org first; the architecture will follow.