System architecture defines the high-level structure of a software system — how it is divided into components, how those components communicate, and what trade-offs were made. Architecture decisions are hard to change: monolith vs microservices, sync vs async communication, SQL vs NoSQL. The SOLID principles guide class and module-level design. The CAP theorem constrains what a distributed system can guarantee simultaneously.
SOLID Principles
The SOLID principles, coined by Robert Martin, describe five properties of well-designed class-level code that make it easier to extend and maintain.
A class should have one reason to change — one job. A User class shouldn't also handle email sending, CSV export, and database persistence. Each responsibility is a reason to change; mixing them makes classes fragile. Split into UserRepository, UserEmailService, UserExporter.
Classes should be open for extension, closed for modification. Add new behaviour by adding new code (implementing an interface, adding a subclass), not by editing existing code. Adding a new payment method shouldn't require editing the existing payment processing logic.
Subclasses should be substitutable for their parent class. If your code expects a Bird and you pass a Penguin (which can't fly), code that calls bird.fly() breaks. The parent's contract must be honoured by all subtypes.
Clients shouldn't be forced to depend on interfaces they don't use. A fat interface with 20 methods forces every implementor to implement all 20, even if they only need 3. Split fat interfaces into smaller, focused ones.
High-level modules shouldn't depend on low-level modules — both should depend on abstractions. OrderService shouldn't import PostgresOrderRepository. It should depend on an OrderRepository interface; the concrete database implementation is injected. This makes swapping implementations (and testing with mocks) possible.
Monolith vs Microservices
This is the most over-debated architectural decision in software. The answer: start with a monolith. Move to microservices only when you have a specific problem the monolith can't solve.
- Simple to develop, deploy, debug
- No network latency between components
- Transactions are easy (single database)
- Easy to refactor across modules
- Right for most teams and most products
- Different services need different scaling
- Independent deployment is required
- Teams are large enough to own separate services
- Different parts need different tech stacks
- Adds: network complexity, distributed transactions, observability overhead
Martin Fowler's "MonolithFirst" pattern: build a monolith first, understand your domain, then extract microservices where the seams are clear. Prematurely splitting into microservices before you understand the domain creates "distributed monoliths" — the worst of both worlds: the deployment complexity of microservices with the coupling of a monolith.
API Design: REST vs GraphQL vs gRPC
The right API style depends on your clients and use case, not fashion.
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Best for | Public APIs, simple CRUD, well-understood resources | Complex data graphs, mobile clients with variable bandwidth, self-documenting APIs | Internal service-to-service, high performance, streaming |
| Over/under-fetching | Common — fixed response shapes | Solved — clients request exactly what they need | Fixed schema (Protocol Buffers) |
| Versioning | /v1/users, /v2/users | Schema evolution via deprecation | Proto field numbering |
| Complexity | Simple | High — N+1 queries, schema design, resolver complexity | Medium — requires proto tooling |
Event-Driven Architecture
Instead of service A calling service B directly (synchronous coupling), A publishes an event to a message broker. B subscribes and reacts asynchronously. This decouples services — A doesn't need to know B exists. Multiple services can react to the same event independently.
# Event-driven: OrderService publishes events,
# other services react independently
# Without events (tight coupling):
class OrderService:
def place_order(self, order):
self.db.save(order)
self.email_service.send_confirmation(order) # coupled to email
self.inventory.reserve(order.items) # coupled to inventory
self.analytics.track("order_placed", order) # coupled to analytics
# adding a new action requires editing this class
# With events (loose coupling):
class OrderService:
def place_order(self, order):
self.db.save(order)
self.event_bus.publish("order.placed", {
"order_id": order.id,
"user_id": order.user_id,
"items": order.items,
"total": order.total,
})
# OrderService is done — doesn't know who listens
# Each subscriber is independent:
class EmailSubscriber:
def on_order_placed(self, event): ...
class InventorySubscriber:
def on_order_placed(self, event): ...
class AnalyticsSubscriber:
def on_order_placed(self, event): ...
# Adding a new subscriber requires no changes to OrderServiceTrade-offs: Event-driven systems are harder to trace and debug (distributed transactions are complex), but they scale better and allow teams to work independently. Use message brokers (Kafka, RabbitMQ, AWS SQS) for production event systems.
CAP Theorem
Eric Brewer's CAP theorem states that a distributed data store can provide at most two of three guarantees simultaneously:
All nodes see the same data at the same time. Every read returns the most recent write.
Every request receives a response — not necessarily the most recent data.
The system works when network messages between nodes are lost or delayed.
Since network partitions are inevitable in distributed systems, real systems must choose between CP (consistent but may be unavailable during partition — e.g. HBase, Zookeeper) or AP (available but may return stale data — e.g. Cassandra, DynamoDB, CouchDB). Most SQL databases are CA — not partition tolerant, which is why they're hard to distribute.
The practical implication: for most web applications, eventual consistency (AP) is acceptable. Your Twitter timeline doesn't need to be perfectly up-to-date to millisecond precision. Your bank balance does.