System Design Deep Dive – Part 1: Foundational Principles for Scalable Systems

Designing reliable, scalable systems goes beyond coding—it starts with strong architectural foundations. In this first part of the System Design Deep Dive series, we explore the key principles that shape robust software architecture:

  • SOLID Principles for object-oriented maintainability
  • ACID Compliance for transactional integrity
  • CAP Theorem trade-offs in distributed systems
  • Consistent Hashing for scalable partitioning
  • Design Patterns that solve recurring design challenges

Each concept is explained with real-world context and Java code examples based on a common use case—user registration and login. Whether you're preparing for system design interviews or building production-grade software, these foundational principles will help you architect systems that are clean, modular, and scalable.


SOLID Principles

SOLID is a set of five foundational object-oriented design principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—that help developers write clean, decoupled, and testable code. Whether you're building new features or refactoring legacy systems, applying SOLID leads to software that's easier to understand, extend, and maintain over time.


S – Single Responsibility

A class should have only one reason to change, meaning it should only have one job or responsibility.

Anti-Example

A class doing too much:

public class UserService {
    public void registerUser(User user) {
        // Validate user input
        // Save user to database
        // Send welcome email
    }
}

Problems

Violates Single Responsibility principle — it’s doing validation, persistence, and communication.

Any change in email logic or validation will force changes to this class.

Refactored

Separate responsibilities:

public class UserService {
    private UserValidator validator;
    private UserRepository repository;
    private EmailService emailService;

    public void registerUser(SecurityProperties.User user) {
        validator.validate(user);
        repository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

Now:

  • UserValidator handles validation
  • UserRepository handles persistence
  • EmailService handles communication

Each class has a single responsibility, making testing and maintenance easier.


O – Open/Closed

Software entities (classes, modules, functions) should be open for extension but closed for modification.

Anti-Example

Hardcoded authentication logic:

public class AuthService {
    public boolean authenticate(String type, String username, String password) {
        if ("basic".equals(type)) {
            return basicAuth(username, password);
        } else if ("oauth".equals(type)) {
            return oauthLogin(username, password);
        }
        return false;
    }
}

Problems

Adding a new auth type (e.g., SSO) requires modifying the class.

Refactored

Use abstraction to allow extension:

public interface AuthStrategy {
    boolean authenticate(String username, String password);
}

public class BasicAuthStrategy implements AuthStrategy {
    public boolean authenticate(String username, String password) {
        // basic auth logic
    }
}

public class OAuthStrategy implements AuthStrategy {
    public boolean authenticate(String username, String password) {
        // oauth logic
    }
}

public class AuthService {
    private AuthStrategy strategy;

    public AuthService(AuthStrategy strategy) {
        this.strategy = strategy;
    }

    public boolean authenticate(String username, String password) {
        return strategy.authenticate(username, password);
    }
}

Now you can add new authentication methods without modifying AuthService.


L – Liskov Substitution

Subtypes must be substitutable for their base types without breaking the application.

Anti-Example

A subclass violates expected behavior:

public class User {
    public boolean isEmailVerified() {
        return true;
    }
}

public class GuestUser extends User {
    public boolean isEmailVerified() {
        throw new UnsupportedOperationException("Guest users don't have emails");
    }
}

Problem

GuestUser cannot be used in place of User — violates Liskov Substitution principle.

Refactored

If GuestUser can't conform to User, it should not extend it. Instead:

public interface Verifiable {
    boolean isEmailVerified();
}

public class RegisteredUser implements Verifiable {
    public boolean isEmailVerified() {
        return true;
    }
}

Use interfaces or composition to ensure substitutability and correct design.


I – Interface Segregation

Clients should not be forced to depend on interfaces they do not use.

Anti-Example

A bloated interface:

public interface UserOperations {
    void register();

    void login();

    void logout();

    void deleteUser();

    void resetPassword();
}

Problem

A GuestUser might only need login() and logout(), not the rest.

Refactored

Segregate interfaces:

public interface AuthOperations {
    void login();

    void logout();
}

public interface AdminOperations {
    void deleteUser();

    void resetPassword();
}

public interface RegistrationOperations {
    void register();
}

Now each class implements only the interfaces it needs.


D – Dependency Inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Anti-Example

High-level class depends on low-level implementation:

public class UserService {
    private MySQLUserRepository repository = new MySQLUserRepository();
}

Problem

Tightly coupled to MySQL. Hard to swap or test.

Refactored

Use abstraction:

public interface UserRepository {
    void save(User user);
}

public class MySQLUserRepository implements UserRepository {
    public void save(User user) {
        // MySQL logic
    }
}

public class UserService {
    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void registerUser(User user) {
        repository.save(user);
    }
}

Now UserService depends on an interface, not a concrete class — easier to test and extend.


ACID Principles

In system design and database architecture, ACID stands for Atomicity, Consistency, Isolation, and Durability — a set of properties that guarantee reliable transaction processing in databases.

Whether you're designing an e-commerce app or a financial system, understanding ACID is essential to prevent data corruption, race conditions, and lost updates.


A — Atomicity

Atomicity ensures that a transaction is all-or-nothing. If one part fails, the entire transaction is rolled back.

Real-World Use Case: Deducting balance and placing an order — either both succeed, or neither does.

try (Connection conn = dataSource.getConnection()) {
    conn.setAutoCommit(false); // Begin transaction

    try (PreparedStatement debit = conn.prepareStatement("UPDATE users SET balance = balance - ? WHERE id = ?")) {
        debit.setDouble(1, 100);
        debit.setInt(2, userId);
        debit.executeUpdate();
    }

    try (PreparedStatement order = conn.prepareStatement("INSERT INTO orders (user_id, amount) VALUES (?, ?)")) {
        order.setInt(1, userId);
        order.setDouble(2, 100);
        order.executeUpdate();
    }

    conn.commit(); // All success, commit transaction
} catch (Exception e) {
    conn.rollback(); // Something failed, roll back
    e.printStackTrace();
}

C — Consistency

Consistency ensures that a transaction brings the database from one valid state to another, adhering to all constraints and rules.

Example: A bank transfer shouldn't allow negative balances if the schema requires positive values.

Schema Constraint Example:

CREATE TABLE users (
    id INT PRIMARY KEY,
    balance DECIMAL(10,2) CHECK (balance >= 0)
);

If a transaction violates this constraint (e.g., deducts more than balance), the DB rejects it, preserving data integrity.


I — Isolation

Isolation ensures that concurrent transactions do not interfere with each other.

Isolation Levels:

  • READ UNCOMMITTED
  • READ COMMITTED (default in many RDBMS)
  • REPEATABLE READ
  • SERIALIZABLE

Example Problem: Two users try to register with the same email at the same time.

Java Example with SERIALIZABLE Isolation:

conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
conn.setAutoCommit(false);

try (PreparedStatement checkEmail = conn.prepareStatement("SELECT * FROM users WHERE email = ?")) {
    checkEmail.setString(1, "oops@coder.com");
    ResultSet rs = checkEmail.executeQuery();

    if (!rs.next()) {
        try (PreparedStatement insertUser = conn.prepareStatement("INSERT INTO users (email) VALUES (?)")) {
            insertUser.setString(1, "oops@coder.com");
            insertUser.executeUpdate();
        }
    }

    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    e.printStackTrace();
}

Using SERIALIZABLE prevents race conditions by locking the read until the write is committed.


D — Durability

Durability guarantees that once a transaction is committed, it will not be lost — even if the system crashes.

This is handled by the database itself through:

  • Write-ahead logs
  • Journaling
  • Disk flush mechanisms

You don’t need to manually write code for durability — it’s ensured when conn.commit() succeeds and the DB engine flushes changes to disk.


💡 Tip: Use Spring @Transactional to manage ACID in a cleaner way. It handles rollback automatically on exceptions.
@Transactional
public void placeOrder(User user, double amount) {
    userRepo.debit(user.getId(), amount);
    orderRepo.createOrder(user.getId(), amount);
}

CAP Theorem

The CAP Theorem states that in the presence of a network partition, a distributed system must choose between Consistency and Availability, but cannot provide both simultaneously.

CAP stands for:

  • Consistency: All nodes see the same data at the same time.
  • Availability: Every request gets a (non-error) response, even if it's not the most recent.
  • Partition Tolerance: The system continues to operate despite communication breakdowns between nodes.

Let’s Break It Down

C — Consistency

All nodes return the latest updated value. After a write, any read should return that write or an error.

Example:
If a user updates their password and immediately logs in from another device, both systems should see the updated password.

Strong consistency is essential in financial and authentication systems.

A — Availability

Every request receives a response (success or failure) — without guaranteeing that the response contains the latest data.

Example:
Even during high traffic or node failure, a product page still loads with data (maybe slightly stale), but the site doesn’t break.

Critical for user-facing apps where uptime is a priority, like e-commerce or social feeds.

P — Partition Tolerance

The system continues operating despite failures or lost communication between nodes (network partitions).

Example:
If a data center in Europe loses connection to the US region, both systems must still serve requests independently.

Partition tolerance is a must in any geographically distributed system.

Discussion

Why Can't We Have All 3?

During a network partition, the system must sacrifice either consistency or availability.

Imagine this:
  • Node A and Node B are in different regions.
  • They lose connection (partition).
  • A user updates their profile on Node A.
  • Another user fetches the profile from Node B.

You now have to choose:

  • Do you block Node B's read (favor Consistency)?
  • Do you return possibly outdated data from Node B (favor Availability)?

Real-World Examples of CAP Trade-Offs

SystemChosen PropertiesExample Use Case
CP SystemConsistency + Partition ToleranceHBase, MongoDB (default), Spanner
AP SystemAvailability + Partition ToleranceCassandra, Couchbase, DynamoDB
CA* SystemConsistency + AvailabilityTraditional RDBMS (non-distributed)
📝 Note: CA systems are only possible if you don't need partition tolerance — which is unrealistic in most real-world systems.

CAP in Practice

CP Example — MongoDB (Majority Write Concern)
db.collection.insertOne({ user: "bob", balance: 100 });
    // Waits for majority of replica set to acknowledge
  • Ensures strong consistency.
  • Will reject writes if majority isn't available.

AP Example — Cassandra (Eventual Consistency)
INSERT INTO users (id, name) VALUES (1, 'Alice');
    // Always succeeds, replicates later
  • Guarantees availability, even during partition.
  • May return stale reads temporarily.

Choosing Between C and A

ScenarioPreferenceReason
Banking / Financial TransactionsCPYou can’t allow inconsistent balance
Social Media FeedAPAvailability matters more
Real-time Chat / MessagingDependsNeeds both freshness and uptime
Search SystemsAPReturning stale results is acceptable

💡 Tip:
Some databases like Cassandra and DynamoDB support tunable consistency — letting you choose C or A per request using quorum settings.

Consistent Hashing

When you're designing a distributed system (like a cache, database, or load balancer), one of the biggest challenges is how to distribute data across multiple nodes efficiently — and how to handle node failures or scaling events without reshuffling everything.

Consistent Hashing is a smart algorithm that addresses exactly that.


The Problem with Simple Hashing

In traditional (modulo-based) hashing:

int node = hash(key) % totalNodes;

Adding or removing a node changes totalNodes, which reassigns almost all keys — causing massive cache invalidation or rebalancing.


What is Consistent Hashing

Consistent Hashing is a partitioning strategy that allows you to:

  • Distribute keys/data across multiple servers
  • Add or remove nodes with minimal reshuffling of keys
  • Improve load balancing using virtual nodes

It's used in systems like Cassandra, DynamoDB, Memcached, and Redis Cluster to scale horizontally while maintaining performance.


The Ring-Based Approach

Consistent Hashing solves this by:

  • Mapping both keys and nodes onto the same hash ring (0 to 2³²–1).
  • Assigning each key to the next node in clockwise direction.
  • When a node is added or removed, only a small subset of keys need to move.


Example (with 3 Nodes)

  • Node A → hash("A") = 10
  • Node B → hash("B") = 60
  • Node C → hash("C") = 120
  • Key X → hash("X") = 65 → assigned to Node C (first node clockwise)

Now you add Node D → hash("D") = 80

  • Only keys between 60–80 (like X) move from Node C to Node D.
📝 Note: Only ~1/N of keys are remapped on node change!

Virtual Nodes (VNodes)

To avoid uneven load (hotspots), each node is represented by multiple virtual nodes placed at different points on the ring.

This smooths out key distribution, especially with a small number of physical servers.

for (int i = 0; i < 100; i++) {
int hash = hash("ServerA" + i);
    ring.put(hash, "ServerA");
}

Discussion

Real-World Use Cases

TechnologyUse of Consistent Hashing
DynamoDBDistributes key-value data across nodes
CassandraToken ring-based data placement
Redis ClusterData sharding among nodes
MemcachedUsed by Facebook for cache consistency
KafkaPartition assignment in producer APIs

Java Implementation (Simplified)

class ConsistentHash<T> {
    private final SortedMap<Integer, T> ring = new TreeMap<>();
    private final int virtualNodes;

    public ConsistentHash(List<T> nodes, int virtualNodes) {
        this.virtualNodes = virtualNodes;
        for (T node : nodes) {
            add(node);
        }
    }

    public void add(T node) {
        for (int i = 0; i < virtualNodes; i++) {
            int hash = (node.toString() + i).hashCode();
            ring.put(hash, node);
        }
    }

    public T get(String key) {
        if (ring.isEmpty()) return null;
        int hash = key.hashCode();
        SortedMap<Integer, T> tail = ring.tailMap(hash);
        hash = tail.isEmpty() ? ring.firstKey() : tail.firstKey();
        return ring.get(hash);
    }
}

Advantages

  • Only a fraction of keys remapped when scaling
  • Better load balancing (with virtual nodes)
  • Highly scalable and fault-tolerant


Disadvantages

  • Slightly complex to implement
  • May still suffer imbalance without VNodes
  • Needs a good hash function to prevent clustering

💡 Tip:
Use MurMurHash or SHA-1 over String.hashCode() for more uniform distribution.

Design Patterns

Design patterns are proven solutions to common software design problems. In Java, mastering these patterns helps you write cleaner, more maintainable, and scalable code by applying reliable architectural best practices.

A design pattern is not a code snippet you copy-paste—it's a reusable approach to solving recurring problems in software design. Think of it as a blueprint that guides you in structuring classes, interfaces, and responsibilities with clarity and flexibility.

Patterns also improve developer communication by providing a shared vocabulary for solving design challenges.

Let’s explore the major categories of design patterns, with examples based on real-world use cases.


Creational Patterns

Creational patterns handle object creation mechanisms, making your code flexible, scalable, and decoupled from specific implementations.


Singleton (Creational)

Ensures only one instance of a class exists and provides a global access point to it.

public class SessionManager {
    private static SessionManager instance = null;

    private SessionManager() {
    }

    public static synchronized SessionManager getInstance() {
        if (instance == null) instance = new SessionManager();
        return instance;
    }

    public void login(String user) { /* store session */ }
}

Use Case: Centralized configuration, logging service, or database connection pool.

🔑 Key Benefit: Controlled access to a single shared resource.

Factory (Creational)

Creates objects without exposing the creation logic to the client. Instead, it uses a common interface.

public interface User {
    void register();
}

public class AdminUser implements User {
    public void register() { /* logic */ }
}

public class GuestUser implements User {
    public void register() { /* logic */ }
}

public class UserFactory {
    public static User create(String type) {
        return switch (type) {
            case "admin" -> new AdminUser();
            case "guest" -> new GuestUser();
            default -> throw new IllegalArgumentException("Unknown type");
        };
    }
}

Use Case: Dynamic creation of objects like user roles, shapes, or messages.

🔑 Key Benefit: Promotes loose coupling and adheres to the Open/Closed Principle.

Abstract Factory (Creational)

Creates families of related objects without specifying their concrete classes.

interface UIComponentFactory {
    Button createButton();

    InputField createInputField();
}

class WebFactory implements UIComponentFactory {
    public Button createButton() {
        return new WebButton();
    }

    public InputField createInputField() {
        return new WebInput();
    }
}

Use Case: Switching UI themes (light/dark), cross-platform UI toolkits, or multiple DB drivers.

🔑 Key Benefit: Supports interchangeable product families while maintaining consistency.

Builder (Creational)

Constructs complex objects step-by-step, separating construction from representation.

public class User {
    private final String email;
    private final String password;
    private final String avatar;

    private User(Builder builder) {
        this.email = builder.email;
        this.password = builder.password;
        this.avatar = builder.avatar;
    }

    public static class Builder {
        private String email, password, avatar;

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public Builder password(String password) {
            this.password = password;
            return this;
        }

        public Builder avatar(String avatar) {
            this.avatar = avatar;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }
}

Use Case: Creating objects like User, Pizza, or Vehicle with optional fields.

🔑 Key Benefit: Avoids constructor overloading and enhances readability.

Prototype (Creational)

Creates new objects by cloning an existing object, useful when object creation is costly.

public class UserTemplate implements Cloneable {
    private String role = "user";

    public UserTemplate clone() throws CloneNotSupportedException {
        return (UserTemplate) super.clone();
    }
}

Use Case: Graphic elements, form templates, or document copies.

🔑 Key Benefit: Offers performance optimization and avoids subclassing.

Structural Patterns

Structural patterns define how classes and objects are composed into larger structures, focusing on reusability and efficient collaboration.


Adapter (Structural)

Allows two incompatible interfaces to work together by providing a wrapper.

interface AppLogin {
    void login(String username, String password);
}

class GoogleAuth {
    void authenticate(String token) { /* logic */ }
}

class GoogleAuthAdapter implements AppLogin {
    GoogleAuth google = new GoogleAuth();

    public void login(String username, String password) {
        String token = username + ":" + password;
        google.authenticate(token);
    }
}

Use Case: Connecting legacy systems or third-party APIs with mismatched interfaces.

🔑 Key Benefit: Promotes code reuse and integration flexibility.

Decorator (Structural)

Dynamically adds new behaviors to objects without modifying their structure.

interface UserService {
    void registerUser(String email);
}

class BasicUserService implements UserService {
    public void registerUser(String email) {
        System.out.println("Registered " + email);
    }
}

class LoggingUserService implements UserService {
    private final UserService service;

    public LoggingUserService(UserService service) {
        this.service = service;
    }

    public void registerUser(String email) {
        service.registerUser(email);
        System.out.println("Log: Registered user " + email);
    }
}

Use Case: Coffee customization, adding logging, stream filtering.

🔑 Key Benefit: Adheres to the Open/Closed Principle while enhancing flexibility.

Proxy (Structural)

Provides a placeholder or surrogate to control access to another object.

interface AccountService {
    void accessAccount();
}

class RealAccountService implements AccountService {
    public void accessAccount() {
        System.out.println("Accessing account");
    }
}

class AuthProxy implements AccountService {
    private final AccountService service;
    private final boolean isAuthenticated;

    public AuthProxy(AccountService service, boolean isAuthenticated) {
        this.service = service;
        this.isAuthenticated = isAuthenticated;
    }

    public void accessAccount() {
        if (isAuthenticated) service.accessAccount();
        else System.out.println("Access denied");
    }
}

Use Case: Lazy loading (virtual proxy), security checks (protection proxy), or caching.

🔑 Key Benefit: Adds control and optimization around resource access.

Facade (Structural)

Offers a simplified interface to a larger body of code, hiding internal complexity.

// Subsystems
class UserValidator {
    public boolean isValid(User user) {
        return user.getEmail() != null && user.getPassword().length() >= 6;
    }
}

class UserRepository {
    public void save(User user) {
        System.out.println("User saved: " + user.getEmail());
    }
}

class EmailService {
    public void sendWelcomeEmail(User user) {
        System.out.println("Welcome email sent to: " + user.getEmail());
    }
}
// Model
class User {
    private String email;
    private String password;

    public User(String email, String password) {
        this.email = email;
        this.password = password;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }
}
// Facade
class UserRegistrationFacade {
    private UserValidator validator = new UserValidator();
    private UserRepository repository = new UserRepository();
    private EmailService emailService = new EmailService();

    public void registerUser(User user) {
        if (validator.isValid(user)) {
            repository.save(user);
            emailService.sendWelcomeEmail(user);
        } else {
            System.out.println("Invalid user details.");
        }
    }
}
// Client
public class Main {
    public static void main(String[] args) {
        User user = new User("john@example.com", "securePass");
        UserRegistrationFacade facade = new UserRegistrationFacade();
        facade.registerUser(user);
    }
}

Use Case: Media players, SDKs, APIs that wrap complex subsystems.

🔑 Key Benefit: Reduces coupling between subsystems and client code.

Behavioral Patterns

Behavioral patterns focus on how objects communicate and interact, enhancing responsibility delegation and runtime flexibility.


Strategy (Behavioral)

Encapsulates a family of algorithms and allows them to be interchanged at runtime.

interface LoginStrategy {
    void login(String input);
}

class EmailLogin implements LoginStrategy {
    public void login(String input) {
        System.out.println("Login via email");
    }
}

class OTPLogin implements LoginStrategy {
    public void login(String input) {
        System.out.println("Login via OTP");
    }
}

class LoginContext {
    private LoginStrategy strategy;

    public void setStrategy(LoginStrategy strategy) {
        this.strategy = strategy;
    }

    public void login(String input) {
        strategy.login(input);
    }
}

Use Case: Sorting algorithms, payment gateway selection, compression strategies.

🔑 Key Benefit: Enables runtime flexibility and adheres to the Open/Closed Principle.

Command (Behavioral)

Encapsulates a request as an object, allowing parameterization and queuing of actions.

interface Command {
    void execute();
}

class RegisterCommand implements Command {
    public void execute() {
        System.out.println("Registering user...");
    }
}

class CommandInvoker {
    public void run(Command command) {
        command.execute();
    }
}

Use Case: Undo/redo systems, task queues, job schedulers.

🔑 Key Benefit: Supports logging, queuing, and undo operations.

Observer (Behavioral)

Defines a one-to-many relationship, where when one object changes, all its dependents are notified.

interface Observer {
    void update(String event);
}

class EmailNotifier implements Observer {
    public void update(String event) {
        System.out.println("Email: " + event);
    }
}

class UserRegistrationSubject {
    private final List<Observer> observers = new ArrayList<>();

    public void addObserver(Observer o) {
        observers.add(o);
    }

    public void registerUser(String user) {
        for (Observer o : observers) o.update("User registered: " + user);
    }
}

Use Case: Event systems, UI frameworks, real-time messaging.

🔑 Key Benefit: Promotes loose coupling between subject and observers.

Memento (Behavioral)

Captures and restores an object’s internal state without violating encapsulation.

class UserMemento {
    String state;

    UserMemento(String state) {
        this.state = state;
    }
}

class UserProfile {
    String state;

    public void setState(String s) {
        this.state = s;
    }

    public UserMemento save() {
        return new UserMemento(state);
    }

    public void restore(UserMemento m) {
        this.state = m.state;
    }
}

Use Case: Undo functionality, versioning systems, game state checkpoints.

🔑 Key Benefit: Enables state recovery while preserving object integrity.

Final Thoughts

Foundational principles aren’t optional—they’re the difference between scalable systems and technical debt.

By mastering SOLID, Design Patterns, ACID, CAP, and Consistent Hashing, you're building the mindset of an architect—one who understands trade-offs, modularity, and reliability. These concepts aren’t just academic; they shape real-world systems where scalability and stability are critical.

💡 Tip: Don’t just memorize patterns—apply them in your own projects, review production codebases, and challenge yourself to think in terms of architecture.

Stay tuned for Part 2, where we’ll explore estimation strategies, system boundaries, and blueprinting your high-level and low-level designs.


Enjoyed the post? 📌 Bookmark it for later and drop a comment — your feedback keeps the content sharp!

Share the Knowledge!
5 2 votes
Article Rating
Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments