Week 2 | Lesson 8

SOLID Principles, Architecture & Application Design

SOLID Principles, Design for Testability, Dependency Injection, Application Architecture, Functional Programming, Design Patterns



© 2026 by Monika Protivová

The SOLID Principle

The SOLID Principle

The SOLID principle is a set of five principles that help us write better code, making it more maintainable, readable, and easier to upgrade and modify.

These principles are not specific to Java, but they are applicable to any object-oriented language.

The SOLID principle is an acronym for the following:

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Single Responsibility Principle

Each class should have a single responsibility or reason to change. This helps to build a system that is better defined, modular, and easier to maintain.

We have seen this principle applied when we talked about encapsulation. Also, you will find that if this principle is applied correctly, your code will be much easier to test.

Open/Closed Principle

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This prevents issues introduced by changes to existing code.
  • Open for extension
    means that the class should be designed in such a way that it can be extended to perform new things.

    Example of this would be extending class with methods to handle new requirements without modifying the existing ones.

  • Closed for modification
    means that once the class has been developed and tested, the code behavior must not change.

    Example of this would be that the class should not be modified to handle new requirements,
    but rather extended, as explained above.

Liskov Substitution Principle

One should be able to use any derived class instead of a parent class and have it behave in the same manner without modification.

We have already seen the Liskov principle applied when we worked with inheritance, abstract classes and interfaces.

Interface Segregation Principle

Many specific client-specific interfaces are better than one general-purpose interface. This means that you should not impose the clients with interfaces that they don't use.

Imagine you have a class that represents a printer. You could have the class implement one interface with methods print(), scan(), etc.

interface Printer { fun print() fun scan() } class SimplePrinter : Printer { /* ... */ } class MultiPrinter : Printer { /* ... */ }

Or you could have the class implement multiple interfaces, each with a single method.

Interface Segregation Principle - Better Approach

Using multiple specific interfaces instead of one general interface.
interface Printing { fun print() } interface Scanning { fun scan() } class SimplePrinter : Printing { /* ... */ } class MultiPrinter : Printing, Scanning { /* ... */ }

This way, SimplePrinter only implements the interface it needs, and MultiPrinter implements both interfaces as required.

Dependency Inversion Principle

One should depend upon abstractions, rather than concrete implementations. This way, modules can remain decoupled, leading to systems that are easier to refactor, change, and deploy.

This is commonly demonstrated when working with collections. We will also see how this is helpful once we start developing applications using Inversion of Control.

Design for Testability

Design for Testability

Following SOLID principles naturally leads to more testable code by promoting better separation of concerns and dependency management.
  • Single Responsibility Principle (SRP)
    → Smaller classes with one job are easier to isolate and test

  • Open/Closed Principle (OCP)
    → Extend functionality without changing existing code, reducing risk of breaking functionality

  • Liskov Substitution Principle (LSP)
    → Use derived classes without breaking functionality, guards against unwanted changes in behavior and also allows for flexible test doubles

  • Interface Segregation Principle (ISP)
    → Smaller, focused interfaces make it easier to implement functionality and also to mock them in tests

  • Dependency Inversion Principle (DIP)
    → Rely on interfaces, so you can easily swap in mocks or fakes

  • Constructor Injection
    → Makes it simple to provide test doubles without frameworks

  • Fewer Dependencies = Simpler Tests
    → Cleaner test setup, fewer mocks, and better reliability

Inversion of Control

Inversion of Control (IoC)

Inversion of Control is a principle in software engineering by which the control is transferred from higher-level components to the lower-level components

This allows the higher-level and lower-level components to focus on their functionalities, promotes better decoupling, more flexibility, and easier maintenance.

Dependency Injection

Dependency Injection is a design pattern that implements IoC. It allows us to inject dependencies into a class, rather than creating them inside the class.

There are three types of dependency injection:

  • Constructor Injection
  • Setter Injection
  • Interface Injection

Without Injection

This example is not using dependency injection. The Car class is tightly coupled with the ElectricEngine class.
fun main() { val car = Car() car.start() car.stop() }
interface Engine { fun on() fun off() }
class ElectricEngine : Engine { override fun on() { println("Electric engine is on") } override fun off() { println("Electric engine is off") } }
class Car { // engine dependency is tightly coupled with Car class private val engine = ElectricEngine() fun start() { engine.on() } fun stop() { engine.off() } }

Constructor Injection

This example is using constructor injection. The Engine dependency is injected into the Car class through its constructor.
fun main() { // First, we need to create an instance of the dependency val engine: Engine = ElectricEngine() // Then we inject the dependency via constructor val car = Car(engine) car.start() car.stop() }
interface Engine { fun on() fun off() }
class ElectricEngine : Engine { override fun on() { println("Electric engine is on") } override fun off() { println("Electric engine is off") } }
// dependency is injected via constructor class Car(private val engine: Engine) { fun start() { engine.on() } fun stop() { engine.off() } }

Setter Injection

This example is using setter injection. The Engine dependency is injected into the Car class through its setter.
fun main() { // First, we need to create an instance of the dependency val engine: Engine = ElectricEngine() // Then we create an instance of the class that has the dependency val car = Car() // Then we inject the dependency via setter car.setEngine(engine) car.start() car.stop() }
interface Engine { fun on() fun off() }
class ElectricEngine : Engine { override fun on() { println("Electric engine is on") } override fun off() { println("Electric engine is off") } }
class Car { private lateinit var engine: Engine // dependency is injected via setter fun setEngine(engine: Engine) { this.engine = engine } fun start() { engine.on() } fun stop() { engine.off() } }

Interface Injection

This is just conceptual example of interface injection. For it to work you need to have some kind of an injector that will inject the dependency into the class that implements the interface.

Application frameworks usually provide such injector for us.

This is just a conceptual example!

@Component class NotificationApplication( private var service: MessageService ) { fun sendNotifications(msg: String, rec: String) { service.sendMessage(msg, rec) } }
interface MessageService { fun sendMessage(msg: String, rec: String) }
@Service class EmailService : MessageService { override fun sendMessage(msg: String, rec: String) { // Logic to send email println("Email sent to " + rec + " with Message=" + msg) } }

Design Patterns

Design Patterns

In software engineering, a design pattern is a general repeatable solution to a commonly occurring problem in software design. They are best practices that the programmer can use to solve common problems when designing an application or system.

As a simplification, we can divide design patterns into 3 categories ...

  • Creational Design Patterns deal with object creation mechanisms, helping with complexities of object creation and providing convenient ways to do so.
    Builder Pattern, Singleton Pattern, Factory Pattern, Abstract Factory Pattern, Prototype Pattern ...

  • Structural Design Patterns concern with composition of classes and objects which form larger structures.
    Decorator Pattern, Adapter Pattern, Proxy Pattern, Composite Pattern, Bridge Pattern ...

  • Behavioral Design Patterns are specifically concerned with communication between objects, how they interact, and distribute the work.
    Observer Pattern, Strategy Pattern, Iterator Pattern, Command Pattern, Template Method Pattern ...

I will not go into detail, but I encourage you to read about them.

Singleton Pattern

with exercises

Singleton Pattern (Creational)

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

The Singleton pattern restricts the instantiation of a class to a single instance. This is useful when exactly one object is needed to coordinate actions across the system.

In Kotlin, the Singleton pattern can be easily implemented using the object declaration, which automatically ensures that only one instance of the object exists.

When to use:

  • Managing shared resources (database connections, configuration, logging)
  • Caching and thread pools
  • Coordinating system-wide actions

Pros & Cons:

  • ✓ Controlled access to single instance
  • ✓ Reduced namespace pollution
  • ✗ Global state can lead to hidden dependencies
  • ✗ Difficulties in unit testing due to tight coupling
  • ✗ Potential issues in multi-threaded environments if not handled properly
  • ✗ Memory leaks if the singleton holds onto resources longer than necessary

Singleton Pattern (Creational)

object DatabaseConnection { private var connectionCount = 0 fun connect() { connectionCount++ println("Database connected. Total connections: $connectionCount") } fun getConnectionCount() = connectionCount } fun main() { DatabaseConnection.connect() DatabaseConnection.connect() println("Count: ${DatabaseConnection.getConnectionCount()}") }

Exercise: Application Logger

Implement a singleton Logger that tracks all application log messages.

Create a singleton Logger object that centralizes application logging.

Requirements:

  1. Create a singleton object Logger
  2. Add a private mutable list logs to store log messages
  3. Implement fun info(message: String) - adds "[INFO] message" to logs
  4. Implement fun error(message: String) - adds "[ERROR] message" to logs
  5. Implement fun getAllLogs(): List<String> - returns all logged messages
  6. Implement fun getErrorCount(): Int - counts error messages

Playground is on the next slide ↓

Exercise: Application Logger

Playground:

object Logger { // TODO: Add your code } fun main() { Logger.info("Application started") Logger.info("User logged in") Logger.error("Failed to load data") Logger.info("Processing request") Logger.error("Network timeout") println("All logs:") Logger.getAllLogs().forEach { println(it) } println("\nTotal errors: ${Logger.getErrorCount()}") }
Expand to see full playground code.

Expected Output:

All logs:
[INFO] Application started
[INFO] User logged in
[ERROR] Failed to load data
[INFO] Processing request
[ERROR] Network timeout

Total errors: 2

Factory Pattern

with exercise

Factory Pattern (Creational)

Defines an interface for creating objects but lets subclasses decide which class to instantiate.

The Factory pattern provides a way to create objects without specifying their exact class. It encapsulates object creation logic and makes the code more flexible and maintainable.

When to use:

  • When object creation logic is complex
  • When you want to decouple client code from concrete classes
  • When you need to create different types based on input parameters

Pros & Cons:

  • ✓ Decouples object creation from usage
  • ✓ Makes code more flexible and maintainable
  • ✗ Can introduce extra complexity
  • ✗ Requires additional classes/methods

Factory Pattern - Kotlin Implementation

interface Transport { fun deliver(destination: String) } class Truck : Transport { override fun deliver(destination: String) = println("Delivering by truck to $destination") } class Ship : Transport { override fun deliver(destination: String) = println("Delivering by ship to $destination") } object TransportFactory { fun createTransport(type: String): Transport = when(type) { "land" -> Truck() "sea" -> Ship() else -> throw IllegalArgumentException("Unknown type") } } fun main() { val transport1 = TransportFactory.createTransport("land") transport1.deliver("New York") val transport2 = TransportFactory.createTransport("sea") transport2.deliver("London") }

Exercise: Refactoring to Factory

Refactor code with hardcoded object instantiation to use the Factory pattern.

The following code has hardcoded instantiation logic. Refactor it using the Factory pattern.

Requirements:

  1. Create a PaymentFactory object
  2. Add fun create(type: String): Payment method
  3. Refactor checkout function to use the factory
  4. Make the code cleaner and easier to extend with new payment types

Benefits of your refactoring:

  • checkout function no longer knows about concrete payment classes
  • ✓ Easy to add new payment methods without modifying checkout
  • ✓ Centralized creation logic

Playground is on the next slide ↓

Exercise: Refactoring to Factory

interface Payment { fun processPayment(amount: Double) } class CreditCard : Payment { override fun processPayment(amount: Double) = println("Processing credit card payment: $$amount") } class PayPal : Payment { override fun processPayment(amount: Double) = println("Processing PayPal payment: $$amount") } class Bitcoin : Payment { override fun processPayment(amount: Double) = println("Processing Bitcoin payment: $$amount") } fun checkout(paymentType: String, amount: Double) { val payment: Payment = when (paymentType) { "credit" -> CreditCard() "paypal" -> PayPal() "bitcoin" -> Bitcoin() else -> error("Unknown payment type") } payment.processPayment(amount) } fun main() { checkout("credit", 100.0) checkout("paypal", 50.0) checkout("bitcoin", 200.0) }

Builder Pattern

with exercise

Builder Pattern (Creational)

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

The Builder pattern is useful when you need to create complex objects with many optional parameters. It provides a fluent interface for building objects incrementally.

When to use:

  • Objects with many optional parameters
  • Complex object construction that requires multiple steps
  • Creating different representations of the same object

Pros & Cons:

  • ✓ Fluent, readable API
  • ✓ Control over construction process
  • ✓ Can create different representations
  • ✗ Requires extra builder class

Builder Pattern - Kotlin Implementation

data class Email( val to: String, val subject: String = "", val body: String = "", val cc: List<String> = emptyList(), val attachments: List<String> = emptyList() ) class EmailBuilder { private var to: String = "" private var subject: String = "" private var body: String = "" private var cc: MutableList<String> = mutableListOf() private var attachments: MutableList<String> = mutableListOf() fun to(address: String) = apply { this.to = address } fun subject(text: String) = apply { this.subject = text } fun body(text: String) = apply { this.body = text } fun cc(address: String) = apply { this.cc.add(address) } fun attach(file: String) = apply { this.attachments.add(file) } fun build() = Email(to, subject, body, cc, attachments) } fun main() { val email = EmailBuilder() .to("john@example.com") .subject("Meeting") .body("See you at 3pm") .cc("jane@example.com") .attach("agenda.pdf") .build() println(email) }

Exercise: Pizza Builder

Build a customizable Pizza using the Builder pattern with multiple toppings.

Create a PizzaBuilder that allows customers to customize their pizza.

  1. Create data class Pizza with:
    • size: String ("small", "medium", "large")
    • cheese: Boolean = true
    • toppings: List<String> = emptyList()
  2. Create class PizzaBuilder with:
    • fun size(s: String) - sets size
    • fun noCheese() - removes cheese
    • fun addTopping(topping: String) - adds one topping
    • fun build(): Pizza - creates pizza
  3. Calculate price: Base ($10) + $2 per topping + size multiplier
  4. Bonus:
    • Use enums instead of strings
    • Limit maximum toppings to 5
    • Create a fun reset() method to start a new pizza

Playground is on the next slide ↓

Exercise: Pizza Builder

data class Pizza(/* TODO */) { fun calculatePrice(): Double { val base = 10.0 val toppingCost = toppings.size * 2.0 val sizeMultiplier = when(size) { "small" -> 1.0 "medium" -> 1.5 "large" -> 2.0 else -> 1.0 } return (base + toppingCost) * sizeMultiplier } } class PizzaBuilder { // TODO: Implement builder } fun main() { val margherita = PizzaBuilder() .size("medium") .addTopping("basil") .build() val meatLovers = PizzaBuilder() .size("large") .addTopping("pepperoni") .addTopping("sausage") .addTopping("bacon") .addTopping("ham") .build() println("$margherita - Price: ${margherita.calculatePrice()}") println("$meatLovers - Price: ${meatLovers.calculatePrice()}") }

Repository Pattern

with exercise

Repository Pattern (Architectural)

Mediates between the domain and data mapping layers, providing a collection-like interface for accessing domain objects.

The Repository pattern abstracts data access logic and provides a centralized way to manage domain objects. It acts as an in-memory collection of objects, hiding the details of data persistence.

When to use:

  • Decoupling business logic from data access
  • Centralizing data access logic
  • Making it easy to switch data sources (database, API, cache, etc.)
  • Improving testability by mocking repositories

Pros & Cons:

  • ✓ Centralized data access logic
  • ✓ Easy to unit test with mock repositories
  • ✓ Hides implementation details from business logic
  • ✗ Can become bloated with many methods
  • ✗ May introduce extra abstraction layer

Repository Pattern - Kotlin Implementation

data class User(val id: Int, val name: String, val email: String) interface UserRepository { fun findById(id: Int): User? fun findAll(): List<User> fun save(user: User) fun delete(id: Int) } class InMemoryUserRepository : UserRepository { private val users = mutableMapOf<Int, User>() private var nextId = 1 override fun findById(id: Int): User? = users[id] override fun findAll(): List<User> = users.values.toList() override fun save(user: User) { val id = if (user.id == 0) nextId++ else user.id users[id] = user.copy(id = id) } override fun delete(id: Int) { users.remove(id) } } fun main() { val repo: UserRepository = InMemoryUserRepository() repo.save(User(0, "Alice", "alice@example.com")) repo.save(User(0, "Bob", "bob@example.com")) println("All users:") repo.findAll().forEach { println(it) } println("\nFind user 1:") println(repo.findById(1)) }

Exercise: Product Repository

Implement an in-memory product repository with CRUD operations and search functionality.

Create a ProductRepository for managing products in an e-commerce system.

Requirements:

  1. Create data class Product(id: Int, name: String, price: Double, category: String)
  2. Create interface ProductRepository with:
    • fun findById(id: Int): Product?
    • fun findAll(): List<Product>
    • fun save(product: Product): Product - returns saved product with ID
    • fun delete(id: Int): Boolean - returns true if deleted
    • fun findByCategory(category: String): List<Product>
    • fun findByPriceRange(min: Double, max: Double): List<Product>
  3. Implement class InMemoryProductRepository
  4. Auto-generate IDs starting from 1

Playground is on the next slide ↓

Exercise: Product Repository

// Your implementation here data class Product( val id: Int, val name: String, val price: Double, val category: String ) interface ProductRepository { // TODO: Add method signatures } class InMemoryProductRepository : ProductRepository { // TODO: Implement repository } fun main() { val repo: ProductRepository = InMemoryProductRepository() // Add products repo.save(Product(0, "Laptop", 999.99, "Electronics")) repo.save(Product(0, "Mouse", 29.99, "Electronics")) repo.save(Product(0, "Desk", 299.99, "Furniture")) repo.save(Product(0, "Chair", 199.99, "Furniture")) println("=== All Products ===") repo.findAll().forEach { println(it) } println("\n=== Electronics ===") repo.findByCategory("Electronics").forEach { println(it) } println("\n=== Products $50-$300 ===") repo.findByPriceRange(50.0, 300.0).forEach { println(it) } println("\n=== After deleting product 2 ===") repo.delete(2) repo.findAll().forEach { println(it) } }

Strategy Pattern

with exercise

Strategy Pattern (Behavioral)

Defines a family of algorithms, encapsulates each one, and makes them interchangeable.

The Strategy pattern lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable. Clients can choose which algorithm to use at runtime.

When to use:

  • Multiple related classes differ only in their behavior
  • You need different variants of an algorithm
  • You want to avoid conditional statements for selecting behavior

Pros & Cons:

  • ✓ Easy to switch algorithms at runtime
  • ✓ Isolates implementation details
  • ✗ Clients must be aware of different strategies
  • ✗ Increases number of classes

Strategy Pattern - Kotlin Implementation

interface SortingStrategy { fun sort(data: MutableList<Int>) } class BubbleSort : SortingStrategy { override fun sort(data: MutableList<Int>) { println("Sorting using Bubble Sort") // Simplified bubble sort for (i in 0 until data.size) { for (j in 0 until data.size - 1 - i) { if (data[j] > data[j + 1]) { val temp = data[j] data[j] = data[j + 1] data[j + 1] = temp } } } } } class QuickSort : SortingStrategy { override fun sort(data: MutableList<Int>) { println("Sorting using Quick Sort") data.sort() // Using built-in sort } } class DataProcessor(private var strategy: SortingStrategy) { fun setStrategy(strategy: SortingStrategy) { this.strategy = strategy } fun process(data: MutableList<Int>) { strategy.sort(data) println("Result: $data") } } fun main() { val processor = DataProcessor(BubbleSort()) processor.process(mutableListOf(5, 2, 8, 1, 9)) processor.setStrategy(QuickSort()) processor.process(mutableListOf(7, 3, 6, 4, 2)) }

Exercise: Payment Strategies

Implement different payment strategies that can be used interchangeably in a checkout system.

Create a payment processing system that supports multiple payment methods using the Strategy pattern.

Requirements:

  1. Create interface PaymentStrategy with fun pay(amount: Double): Boolean
  2. Implement CreditCardPayment(cardNumber: String) strategy
    • Validates card number (must be 16 digits)
    • Prints "Paid $amount using Credit Card ending in ****${cardNumber.takeLast(4)}"
  3. Implement PayPalPayment(email: String) strategy
    • Validates email (must contain '@')
    • Prints "Paid $amount via PayPal account $email"
  4. Implement CryptoPayment(walletAddress: String) strategy
    • Prints "Paid $amount to crypto wallet $walletAddress"
  5. Create class ShoppingCart that uses PaymentStrategy

Playground is on the next slide ↓

Exercise: Payment Strategies

// Your implementation here interface PaymentStrategy { fun pay(amount: Double): Boolean } // TODO: Implement payment strategies class ShoppingCart { private val items = mutableListOf<Pair<String, Double>>() private var paymentStrategy: PaymentStrategy? = null fun addItem(name: String, price: Double) { items.add(name to price) } fun setPaymentMethod(strategy: PaymentStrategy) { this.paymentStrategy = strategy } fun checkout(): Boolean { val total = items.sumOf { it.second } println("Total: $${"%.2f".format(total)}") return paymentStrategy?.pay(total) ?: false } } fun main() { val cart = ShoppingCart() cart.addItem("Laptop", 999.99) cart.addItem("Mouse", 29.99) println("=== Paying with Credit Card ===") cart.setPaymentMethod(CreditCardPayment("1234567890123456")) cart.checkout() println("\n=== Paying with PayPal ===") cart.setPaymentMethod(PayPalPayment("user@example.com")) cart.checkout() println("\n=== Paying with Crypto ===") cart.setPaymentMethod(CryptoPayment("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")) cart.checkout() }

Application Architecture

Common Architecture Patterns

There are several common architecture patterns that are used in the development of software applications.

These are high-level application architecture patterns used to define the structure of the application and the way in which the different components of the application interact with each other.

of the most common architecture patterns include:

  • Monolithic Architecture
  • Microservice Architecture
  • API Gateway Architecture
  • Serverless Architecture
  • Event-Driven Architecture
  • Hexagonal Architecture

Monolithic Architecture

Monolithic architecture is a software architecture pattern where all the components of the application run as a single unit.

Monolithic architecture tends to be tightly coupled, which can make it difficult to scale and maintain the application over time.

  1. When to use
    • Large, complex systems.
    • Early - stage startups.
    • Teams with limited resources.
  2. Pros
    • Independent scaling and deployment.
    • Fault isolation (failures in one service don't affect others).
    • Easier to adopt different tech stacks per service.
  3. Cons
    • Complex to manage (e.g., inter-service communication).
    • Potential for high latency between services.
    • Requires solid observability (monitoring, logging).
    • Difficult to develop and deploy. **Monoliths can have many dependencies to for developers, that may mean a lot of setup.

Monolithic Architecture Diagram

User Interface Layer (Web UI, Mobile, Desktop) Business Logic Layer - User Management - Product Catalog - Order Processing - Payment Processing Data Access Layer (ORM, Repository Pattern) Database (Single shared database)

Microservice Architecture

An architecture that decomposes the system into smaller, (relatively) independent services.
  1. When to use
    • Small projects or MVPs.
    • Early - stage startups.
    • Teams with limited resources.
  2. Pros
    • Easier debugging with one codebase.
    • Simpler to develop and deploy. **In terms of needing simper infrastructure.
  3. Cons
    • Difficult to scale certain parts independently.
    • Harder to manage as the codebase grows.
    • Limited error resilience.
  4. Pitfalls

Microservice Architecture Diagram

Client API Gateway User Service - Auth - Registration Product Service - CRUD - Search Order Service - Create - Update Payment Service - Pay - Verify User DB Product DB Order DB Payment DB Each service is independently deployable, scalable, and has its own database

API Gateway Architecture

Centralizes all API traffic, often for microservices and serverless backends.
  1. When to use
    • Systems with multiple backend services.
    • Applications that require centralized authentication, logging, or throttling.
  2. Pros
    • Centralized management for APIs.
    • Reduced cross-cutting concerns (e.g., rate limiting, CORS).
  3. Cons
    • Potential single point of failure.
    • Latency and complexity if overloaded.

API Gateway Architecture Diagram

Web Mobile IoT Desktop API Gateway • Authentication / OAuth • Rate Limiting / Throttling • Request Routing • Response Aggregation • Caching • Monitoring & Logging • Protocol Translation User Service Product Service Order Service Payment Service API Gateway acts as single entry point, handling cross-cutting concerns and routing requests to appropriate backend services

Serverless Architecture

Uses cloud-managed services where you write and deploy functions instead of managing infrastructure.
  1. When to use
    • Event-driven systems (e.g., IoT data, notifications).
    • Applications with unpredictable or spiky traffic.
  2. Pros
    • Automatic scaling.
    • Reduced operational complexity.
    • Pay-per-use model.
  3. Cons
    • Cold-start latency.
    • Vendor lock-in risks.
    • Debugging and monitoring can be more complex.

Serverless Architecture Diagram

S3 Upload API Gateway EventBridge DynamoDB Lambda Functions Image Processor User Handler Event Processor Data Transformer DynamoDB S3 SQS SNS Event-driven, auto-scaling functions Pay only for execution time • No server management Event Sources Managed Services

Event-Driven Architecture

Systems that react to and propagate events across services (e.g., Kafka, RabbitMQ).
  1. When to use
    • Systems with high-frequency event generation.
    • IoT and real-time analytics.
    • Applications with loosely coupled services.
  2. Pros
    • Real-time processing and responsiveness.
    • Highly scalable and decoupled.
  3. Cons
    • Requires careful event design to avoid excessive coupling.
    • Complex debugging and replaying of events.

Event-Driven Architecture Diagram

Order Service (Publisher) User Service (Publisher) Payment Service (Publisher) Event Bus / Message Broker (Kafka / RabbitMQ / EventBridge) Topic: OrderCreated Topic: UserRegistered Topic: PaymentProcessed Email Service (Subscriber) Analytics Service (Subscriber) Inventory Service (Subscriber) Audit Service (Subscriber) Asynchronous, loosely coupled communication Services publish events without knowing subscribers • High scalability

Hexagonal Architecture

Also known as Ports and Adapters. Isolates the core business logic from external concerns.
  1. When to use
    • Complex domain logic that needs to be tested in isolation.
    • Applications that need to support multiple interfaces (UI, API, CLI).
    • Systems where business rules change frequently.
  2. Pros
    • High testability - business logic isolated from infrastructure.
    • Flexibility - easy to swap adapters (databases, frameworks).
    • Clear separation of concerns.
  3. Cons
    • Initial complexity and learning curve.
    • More boilerplate code (interfaces, adapters).
    • Can be overkill for simple CRUD applications.

Hexagonal Architecture Diagram

Domain / Core Business Logic • Use Cases • Domain Models • Business Rules Primary Adapters (Driving) REST API Web UI CLI Secondary Adapters (Driven) PostgreSQL Email Service External API Port Port Port Port Port Port Ports define interfaces, Adapters implement them Business logic has no dependencies on external frameworks or infrastructure Business Logic User-Facing Infrastructure Ports

How to decide?

You will get it wrong!

And you will re-do it.

And you will still get it wrong again.

And then you will re-do it again.

And it will never be perfect.

And that's OK.


Embrace failure, learn from it, and go on.

Practice

Practice: Coffee Shop SOLID Refactoring

Refactor the Coffee Shop system using SOLID principles, service layer architecture, and dependency injection

The Challenge

You'll receive an expanded Coffee Shop codebase with SOLID violations. Identify the violations and refactor into a well-architected application.

SOLID Violations to Find

  • SRP Violation - CoffeeShop class handles orders, pricing, receipts, inventory, and reporting
  • OCP Violation - Adding new drink types requires modifying PriceCalculator's when expression
  • DIP Violation - CoffeeShop directly instantiates FilePersistence instead of depending on an abstraction

Refactoring Target

  1. Extract PricingService interface + StandardPricingService implementation
  2. Extract OrderService interface + OrderServiceImpl
  3. Extract ReceiptService interface + ReceiptServiceImpl
  4. Create CoffeeShopApplication that accepts services via constructor injection

Key Benefits

  • Testability - Easy to test with mocks
  • Maintainability - Changes are localized
  • Flexibility - Easy to swap implementations
  • Readability - Clear separation of concerns