Choosing a monolith over microservices often comes down to embracing complexity rather than distributing it.
Let’s see a simple monolith in action. Imagine a basic e-commerce application.
# app.py
from flask import Flask, render_template, request, redirect, url_for
app = Flask(__name__)
# In-memory "database" for simplicity
products = {
1: {"name": "Laptop", "price": 1200.00, "stock": 10},
2: {"name": "Keyboard", "price": 75.00, "stock": 50},
3: {"name": "Mouse", "price": 25.00, "stock": 100},
}
cart = {} # {product_id: quantity}
@app.route('/')
def index():
return render_template('index.html', products=products)
@app.route('/add_to_cart/<int:product_id>', methods=['POST'])
def add_to_cart(product_id):
if product_id in products:
quantity = cart.get(product_id, 0) + 1
if products[product_id]["stock"] >= quantity:
cart[product_id] = quantity
products[product_id]["stock"] -= 1
else:
# Basic error handling
return "Not enough stock!", 400
return redirect(url_for('view_cart'))
@app.route('/cart')
def view_cart():
cart_items = []
total_price = 0
for product_id, quantity in cart.items():
product = products[product_id]
item_total = product["price"] * quantity
cart_items.append({
"name": product["name"],
"price": product["price"],
"quantity": quantity,
"item_total": item_total
})
total_price += item_total
return render_template('cart.html', cart_items=cart_items, total_price=total_price)
@app.route('/checkout', methods=['POST'])
def checkout():
# In a real app, this would involve payment processing, order creation, etc.
global cart
cart = {} # Clear the cart after checkout
return "Thank you for your order!", 200
if __name__ == '__main__':
app.run(debug=True)
<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Product List</title>
</head>
<body>
<h1>Our Products</h1>
<ul>
{% for id, product in products.items() %}
<li>
{{ product.name }} - ${{ "%.2f"|format(product.price) }} (Stock: {{ product.stock }})
<form action="{{ url_for('add_to_cart', product_id=id) }}" method="post">
<button type="submit">Add to Cart</button>
</form>
</li>
{% endfor %}
</ul>
<a href="{{ url_for('view_cart') }}">View Cart</a>
</body>
</html>
<!-- templates/cart.html -->
<!DOCTYPE html>
<html>
<head>
<title>Your Cart</title>
</head>
<body>
<h1>Your Shopping Cart</h1>
{% if cart_items %}
<ul>
{% for item in cart_items %}
<li>{{ item.name }} - Quantity: {{ item.quantity }} - Total: ${{ "%.2f"|format(item.item_total) }}</li>
{% endfor %}
</ul>
<p><strong>Grand Total: ${{ "%.2f"|format(total_price) }}</strong></p>
<form action="{{ url_for('checkout') }}" method="post">
<button type="submit">Proceed to Checkout</button>
</form>
{% else %}
<p>Your cart is empty.</p>
{% endif %}
<p><a href="{{ url_for('index') }}">Continue Shopping</a></p>
</body>
</html>
This monolith is a single deployable unit. All the logic—product catalog, shopping cart management, and checkout initiation—resides in one codebase, running as a single process. When you want to add a new feature, like a "wishlist," you modify this single application. If you need to scale, you run multiple copies of this entire application behind a load balancer.
The core problem a monolith solves is simplicity of development and deployment for small, tightly coupled systems. For a new startup with a small team and an evolving product, the overhead of managing distributed systems (like microservices) can be a significant drag. You don’t need to worry about inter-service communication, distributed tracing, eventual consistency across services, or managing multiple deployment pipelines. All your data is in one database, all your code in one repository, and all your tests run against a single application. This allows for rapid iteration and quick feedback loops.
The mental model for a monolith is that of a single, cohesive unit. All components are aware of each other and can call each other directly, usually via function calls. Data is typically managed in a single, shared database, simplifying transactions and ensuring strong consistency. Development focuses on adding features or improving existing ones within this single structure.
When you’re building something new, and the domain boundaries aren’t yet clear, a monolith gives you the flexibility to refactor and reorganize internally without the immediate cost of distributed system changes. You can evolve the internal structure of your code as your understanding of the business domain deepens. The "tightly coupled" nature, often seen as a drawback, is actually a strength in this context, allowing for straightforward data manipulation and immediate consistency.
The most significant advantage of a monolith, especially early on, is the ease of debugging. When an error occurs, it’s usually within a single process. You can attach a debugger, inspect variables, and trace the execution flow directly. There’s no need to correlate logs across multiple services or worry about network latency causing unexpected behavior between components. This direct observability is invaluable when you’re still discovering what your application needs to do.
The next problem you’ll likely encounter is how to manage the growing complexity within that single monolith as your team and feature set expand.