Master software engineering principles, Object-Oriented Analysis and Design (OOAD), and advanced testing methodologies.
February 2026
Software Engineering is the application of a systematic, disciplined, quantifiable approach to the development, operation, and maintenance of software. It is not just about writing code; it encompasses the entire lifecycle of a software product, from inception to retirement.
In the late 1960s, the term “Software Crisis” was coined to describe the difficulties in writing efficient, error-free, and maintainable software. Projects were frequently over budget, behind schedule, and failed to meet user requirements. This led to the realization that software development needed to transition from a “craft” to an “engineering discipline.”
While Computer Science focuses on the theory and fundamentals of computation (algorithms, data structures, complexity), Software Engineering focus on the practicalities of developing and delivering useful software.
One of the primary goals of software engineering is to manage complexity. As systems grow, they become harder to understand. We use tools like abstraction and modularity to break systems into smaller, manageable parts.
# A simple example of modularity by separating concerns
class UserManager:
def __init__(self):
self.users = []
def add_user(self, name):
self.users.append(name)
print(f"User {name} added.")
class Logger:
@staticmethod
def log(message):
print(f"[LOG]: {message}")
# Usage
logger = Logger()
manager = UserManager()
manager.add_user("Alice")
logger.log("Alice was added to the system.")
Software engineers have responsibilities to their profession and to society. This includes maintaining confidentiality, being competent, and not misrepresenting their skills. Ethical considerations are paramount, especially as software controls more critical infrastructure.
The engineering mindset involves:
In the following lessons, we will dive deeper into the methodologies and design patterns that make high-quality software development possible.
An SDLC is a structured process used by the software industry to design, develop, and test high-quality software. The SDLC aims to produce high-quality software that meets or exceeds customer expectations, reaches completion within times and cost estimates.
The Waterfall model is a linear-sequential life cycle model. In a waterfall model, each phase must be completed before the next phase can begin and there is no overlapping in the phases.
Pros: Simple and easy to understand and use. Cons: High risk and uncertainty; not a good model for complex and object-oriented projects.
An extension of the waterfall model, the V-model associates a testing phase with each development phase.
Agile is an iterative, incremental approach to software development. It focuses on flexibility, continuous improvement, and rapid delivery.
Imagine we are building a feature in sprints.
def sprint(backlog, duration_weeks=2):
print(f"Starting {duration_weeks}-week sprint...")
completed_tasks = []
for task in backlog:
if can_complete(task):
work_on(task)
completed_tasks.append(task)
return completed_tasks
project_backlog = ["Login UI", "Database Setup", "Auth Logic", "Profile Page"]
sprint_1_results = sprint(project_backlog[:2])
print(f"Delivered: {sprint_1_results}")
Modern SDLCs often incorporate DevOps practices. Continuous Integration (CI) involves automatically testing and building the application every time a change is made to the codebase. Continuous Deployment (CD) goes a step further by automatically deploying those changes to production.
The choice of an SDLC depends on:
For established projects with well-defined requirements, Waterfall might still be appropriate. However, for most modern web and mobile applications, Agile is the industry standard.
Object-Oriented Programming (OOP) is a programming paradigm based on the concept of “objects,” which can contain data and code. Data in the form of fields (attributes or properties), and code in the form of procedures (methods).
The four pillars of OOP are Abstraction, Encapsulation, Inheritance, and Polymorphism.
Abstraction is the concept of hiding the internal details and showing only the functionality. It helps in reducing programming complexity and effort.
In C++, we use classes to provide abstraction.
#include <iostream>
using namespace std;
class CoffeeMachine {
public:
void makeCoffee() {
boilWater();
brewBeans();
cout << "Coffee is ready!" << endl;
}
private:
void boilWater() { cout << "Boiling water..." << endl; }
void brewBeans() { cout << "Brewing beans..." << endl; }
};
int main() {
CoffeeMachine myMachine;
myMachine.makeCoffee(); // User only knows how to make coffee, not the internals.
return 0;
}
Encapsulation is the bundling of data with the methods that operate on that data. It restricts direct access to some of the object’s components, which is a means of preventing accidental interference and misuse of the data.
public class BankAccount {
private double balance; // Data is hidden
public void deposit(double amount) {
if (amount > 0) balance += amount;
}
public double getBalance() {
return balance;
}
}
Inheritance is a mechanism in which one object acquires all the properties and behaviors of a parent object. it represents the IS-A relationship.
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
Polymorphism allows objects of different types to be treated as objects of a common base type. The most common use is when a parent class reference is used to refer to a child class object.
Animal myDog = new Dog();
Animal myCat = new Cat();
Animal[] animals = {myDog, myCat};
for (Animal a : animals) {
System.out.println(a.speak()); // Different behavior for different types
}
Understanding these fundamentals is the first step toward Object-Oriented Analysis and Design (OOAD), where we model complex real-world systems into these structures.
One of the most powerful concepts in Software Engineering and OOAD is the separation of Interface and Implementation.
When you “program to an interface,” you write code that interacts with an abstraction (an interface or abstract class) rather than a concrete class. This makes your code more flexible and easier to change.
Imagine an application that needs to save data. You could save it to a File or a Database.
class Database {
public:
void saveToDB(string data) { /* logic */ }
};
class App {
Database db; // Hard dependency on concrete class
public:
void run() { db.saveToDB("User Data"); }
};
If you want to switch to a file system, you must rewrite the App class.
// The Interface
class IDataStore {
public:
virtual void save(string data) = 0;
};
// Implementation 1
class DatabaseStore : public IDataStore {
public:
void save(string data) override { cout << "Saving to DB..." << endl; }
};
// Implementation 2
class FileStore : public IDataStore {
public:
void save(string data) override { cout << "Saving to File..." << endl; }
};
// The App depends on the interface
class App {
IDataStore* store;
public:
App(IDataStore* s) : store(s) {}
void run() { store->save("User Data"); }
};
public interface PaymentProcessor {
void process(double amount);
}
public class StripeProcessor implements PaymentProcessor {
public void process(double amount) {
System.out.println("Processing via Stripe: $" + amount);
}
}
By separating implementation from interface, we create a layer of abstraction that protects the rest of our system from changes in low-level details. This is a recurring theme in all design patterns.
In Object-Oriented design, classes don’t exist in isolation. They interact with each other. We categorize these interactions into several types of relationships.
Association is the most general relationship. It represents a “has-a” or “uses-a” relationship where objects have their own independent lifecycle and there is no ownership.
class Teacher:
def __init__(self, name):
self.name = name
class Student:
def __init__(self, name):
self.name = name
# A teacher is associated with a student, but they exist independently.
t = Teacher("Mr. Smith")
s = Student("Alice")
Aggregation is a special form of association. It represents a “part-of” relationship where the part can exist independently of the whole. The parent “aggregates” the child.
Lifecycle: If the parent is destroyed, the child usually survives.
class Department {
private List<Employee> employees;
// If the Department is closed, the employees still exist.
}
Composition is a strong form of aggregation. It represents a “part-of” relationship where the part cannot exist independently of the whole.
Lifecycle: If the parent is destroyed, the child is also destroyed.
class Heart {
public:
void beat() {}
};
class Human {
Heart heart; // The heart is part of the human.
public:
// If Human is destroyed, heart is destroyed too.
};
In UML and OOAD, we also define how many instances are involved:
| Feature | Association | Aggregation | Composition |
|---|---|---|---|
| Relationship | Link between two classes | Weak “Has-A” | Strong “Has-A” |
| Lifecycle | Independent | Independent | Dependent |
| Example | Teacher & Student | Department & Teacher | House & Room |
| UML Symbol | Straight Line | Hollow Diamond | Filled Diamond |
Choosing the right relationship type dictates:
In Design Patterns, we often prefer Composition over Inheritance to allow for more flexible code reuse without the rigid hierarchies that inheritance creates.
The Single Responsibility Principle is the “S” in SOLID. It states that:
“A class should have one, and only one, reason to change.” — Robert C. Martin
A “reason to change” is synonymous with a “responsibility”. If a class handles multiple disparate tasks, it has multiple responsibilities.
This class handles user data, validation, and database saving.
class User:
def __init__(self, username, email):
self.username = username
self.email = email
def validate_email(self):
return "@" in self.email
def save_to_db(self):
print(f"Saving {self.username} to database...")
If the database schema changes, we change the User class. If the validation logic changes, we change the User class. This class has two reasons to change.
We split the responsibilities into separate classes.
class User:
def __init__(self, username, email):
self.username = username
self.email = email
class UserValidator:
@staticmethod
def validate(user):
return "@" in user.email
class UserRepository:
def save(self, user):
print(f"Persisting {user.username} to DB...")
Consider a class that manages a Report and also prints it.
// Violation
class Report {
string content;
public:
void generate() { /* ... */ }
void print() { cout << content << endl; } // Printing logic mixed with data logic
};
// Fixed
class Report {
string content;
public:
string getContent() { return content; }
};
class ReportPrinter {
public:
void print(Report& r) { cout << r.getContent() << endl; }
};
UserValidator can be reused in different parts of the app without bringing in the database logic.UserValidator don’t need to know about databases.The Open/Closed Principle is the “O” in SOLID. It was defined by Bertrand Meyer and popularized by Robert C. Martin. It states:
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
You should be able to add new functionality to a system without changing the existing source code. Instead of editing an existing class, you should extend it (via inheritance or composition).
When you modify existing code:
if-else or switch statements.class Rectangle {
public double width;
public double height;
}
class AreaCalculator {
public double calculateArea(Object[] shapes) {
double area = 0;
for (Object shape : shapes) {
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
area += r.width * r.height;
}
// If we add 'Circle', we MUST modify this class!
}
return area;
}
}
We use abstraction to make the calculator “closed” to changes in shape types.
interface Shape {
double getArea();
}
class Rectangle implements Shape {
public double width, height;
public double getArea() { return width * height; }
}
class Circle implements Shape {
public double radius;
public double getArea() { return Math.PI * radius * radius; }
}
class AreaCalculator {
public double calculateArea(Shape[] shapes) {
double area = 0;
for (Shape shape : shapes) {
area += shape.getArea();
}
return area;
}
}
Now, if we want to add a Triangle, we just create a new class. We never touch AreaCalculator again.
C++ often uses pointers to abstract classes to implement OCP.
class Logger {
public:
virtual void log(string msg) = 0;
};
class ConsoleLogger : public Logger {
void log(string msg) override { cout << msg << endl; }
};
class FileLogger : public Logger {
void log(string msg) override { /* write to file */ }
};
class App {
Logger* logger;
public:
App(Logger* l) : logger(l) {}
void doSomething() {
logger->log("Something happened");
}
};
OCP is powerful but can lead to over-engineering. Apply it:
OCP is about looking into the future. By using interfaces and abstractions, we create systems that are “immune” to changes in business requirements. We grow our software by adding new code, not by hacking old code.
The Liskov Substitution Principle (LSP) is the “L” in SOLID. It was introduced by Barbara Liskov in 1987. It states:
“Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.”
In simpler terms: A derived class should complement its base class, not contradict it.
In geometry, a square is a rectangle. However, in software, this can lead to logic errors if not handled carefully.
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
def set_width(self, w): self._width = w
def set_height(self, h): self._height = h
def get_area(self): return self._width * self._height
class Square(Rectangle):
def set_width(self, w):
self._width = w
self._height = w # Constraints of a square
def set_height(self, h):
self._width = h
self._height = h
Now, imagine a function that expects a Rectangle:
def increase_width(rect: Rectangle):
rect.set_width(10)
rect.set_height(5)
# For a classic Rectangle, area should be 50.
# For a Square, area will be 25 because set_height(5) overrode the width!
assert rect.get_area() == 50 # This fails if rect is a Square!
The Square class is not a valid substitute for Rectangle in this context.
Instead of inheritance, use a shared interface or rethink the hierarchy.
class Shape:
def get_area(self):
pass
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def get_area(self): return self.w * self.h
class Square(Shape):
def __init__(self, side):
self.side = side
def get_area(self): return self.side ** 2
A common LSP violation is when a subclass implements a method but leaves it empty or throws a “NotImplementedException”.
public interface Bird {
void fly();
}
public class Duck implements Bird {
public void fly() { System.out.println("Flying..."); }
}
public class Ostrich implements Bird {
public void fly() {
throw new UnsupportedOperationException("Ostriches can't fly!");
}
}
If you pass an Ostrich to a function expecting Bird, and that function calls .fly(), the program crashes. Ostrich broke the contract of Bird.
FlyingBird vs SwimmingBird).T will behave according to T’s contract.The Interface Segregation Principle (ISP) is the “I” in SOLID. It states:
“Clients should not be forced to depend on methods they do not use.”
This principle is about reducing the coupling between a class and its dependencies by making interfaces as specific as possible.
A “Fat” or “Polluted” interface is one that contains too many methods. When a class implements a fat interface, it often ends up with “dummy” implementations for methods it doesn’t need.
class IMachine {
public:
virtual void print() = 0;
virtual void scan() = 0;
virtual void fax() = 0;
};
// A high-end printer can do everything
class AllInOnePrinter : public IMachine {
void print() override { /* ... */ }
void scan() override { /* ... */ }
void fax() override { /* ... */ }
};
// An old printer can only print
class OldPrinter : public IMachine {
void print() override { /* printing */ }
void scan() override { /* ERROR: I can't scan! */ }
void fax() override { /* ERROR: I can't fax! */ }
};
OldPrinter is forced to depend on scan() and fax(), even though it doesn’t support them.
We split the fat interface into smaller, more focused ones.
class IPrinter {
public:
virtual void print() = 0;
};
class IScanner {
public:
virtual void scan() = 0;
};
class IFax {
public:
virtual void fax() = 0;
};
// Now we can compose them
class AllInOnePrinter : public IPrinter, public IScanner, public IFax {
void print() override { ... }
void scan() override { ... }
void fax() override { ... }
};
class OldPrinter : public IPrinter {
void print() override { ... }
};
Java’s standard library is a great example of ISP. Instead of a BigObjectInterface, we have interfaces like Serializable, Cloneable, Iterable, and Comparable. Each defines a very specific role.
public interface Document {
void open();
void save();
}
public interface Encryptable {
void encrypt();
}
public class SecureDocument implements Document, Encryptable {
public void open() { ... }
public void save() { ... }
public void encrypt() { ... }
}
public class BasicDocument implements Document {
public void open() { ... }
public void save() { ... }
}
NotImplementedException or are left empty.ISP teaches us to design for the client. By providing small, purpose-built interfaces, we ensure that our components only interact with the parts of the system they actually need.
The Dependency Inversion Principle is the final “D” in SOLID. It states:
In traditional software development, high-level logic (e.g., Business Rules) often depends directly on low-level details (e.g., Database drivers, API clients). This makes the high-level logic fragile and hard to test.
class EmailService:
def send(self, message):
print(f"Sending email: {message}")
class PasswordReset:
def __init__(self):
self.service = EmailService() # Hard dependency on low-level detail
def reset(self):
# logic...
self.service.send("Your password was reset.")
Here, PasswordReset (High-level) depends on EmailService (Low-level). If we want to send an SMS instead, we must modify the PasswordReset class.
We introduce an abstraction (Interface) between them.
from abc import ABC, abstractmethod
# The Abstraction
class MessageService(ABC):
@abstractmethod
def send(self, message):
pass
# Low-level implementation
class EmailService(MessageService):
def send(self, message):
print(f"Email: {message}")
class SMSService(MessageService):
def send(self, message):
print(f"SMS: {message}")
# High-level module depends on the abstraction
class PasswordReset:
def __init__(self, service: MessageService):
self.service = service # Dependency Injection
def reset(self):
self.service.send("Reset link sent.")
While they are related, they are not the same:
In Java, we often use frameworks to handle the “Inversion of Control”.
public interface UserRepository {
void save(User user);
}
public class UserService {
private final UserRepository repo;
// The service doesn't know WHO implements UserRepository
public UserService(UserRepository repo) {
this.repo = repo;
}
public void register(User user) {
repo.save(user);
}
}
It is called “Inversion” because it flips the traditional dependency graph.
The SOLID principles work together to create software that is easy to maintain and extend. By following the Dependency Inversion Principle, you ensure that your most valuable code (Business Logic) is protected from the volatility of external tools and frameworks.
Object-Oriented Analysis (OOA) is the first technical step in the software development lifecycle where we focus on what the system should do, rather than how it should do it. The goal is to create a model of the functional requirements that is independent of implementation details.
Requirements are the “wants and needs” of the stakeholders. In OOA, we categorize them into two main types:
These describe the specific behaviors, services, and tasks the system must perform.
These describe how the system performs its functions—attributes like performance, security, and usability.
Use case modeling is the primary tool for OOA. It helps identify the boundaries of the system and the interactions between the system and its environment.
@startuml
left to right direction
actor "Customer" as C
actor "Bank System" as B
rectangle "ATM System" {
use case "Withdraw Cash" as UC1
use case "Check Balance" as UC2
use case "Deposit Funds" as UC3
use case "Transfer Money" as UC4
}
C --> UC1
C --> UC2
C --> UC3
C --> UC4
UC1 -- B
UC4 -- B
@enduml
A diagram is not enough. Each use case needs a description, often written as a “Success Scenario.”
Use Case: Withdraw Cash
How do we find these requirements?
During OOA, we also perform Domain Modeling. This involves identifying the “nouns” in our requirements which will eventually become our classes.
Identifying these early ensures that the software structure mirrors the real-world domain, a core tenet of Object-Oriented Design.
While Analysis focuses on the what, Object-Oriented Design (OOD) focuses on the how. It involves defining the software objects and how they collaborate to fulfill the requirements identified during analysis.
The Class Diagram is the heart of OOD. It describes the structure of a system by showing its classes, their attributes, operations, and the relationships among objects.
A class is represented by a rectangle divided into three parts:
Understanding relationships is critical for creating a modular system:
A generic relationship where one class “uses” or “knows about” another.
Teacher teaches a Student.A “part-of” relationship where the “part” can exist independently of the “whole”.
Library has Books. If the library closes, the books still exist.A “part-of” relationship where the “part” cannot exist without the “whole”.
House has Rooms. If the house is destroyed, the rooms are too.Inheritance between a superclass and a subclass.
Dog is an Animal.@startuml
class Car {
- String model
- Engine engine
+ void start()
}
class Engine {
- int horsepower
+ void ignite()
}
class Wheel {
- double pressure
}
Car *-- Engine : composition
Car o-- Wheel : aggregation
@enduml
While class diagrams show static structure, Sequence Diagrams show how objects interact over time to perform a specific task (usually a use case).
@startuml
actor User
participant "LoginUI" as UI
participant "AuthService" as Auth
database "UserDB" as DB
User -> UI: Enter Credentials
UI -> Auth: login(username, pass)
Auth -> DB: fetchUser(username)
DB --> Auth: userObject
Auth -> Auth: validatePassword()
Auth --> UI: success
UI --> User: Show Dashboard
@enduml
Multiplicity defines how many instances of one class can be associated with one instance of another.
1: Exactly one.0..1: Zero or one.* or 0..*: Many.1..*: One or many.Here is how a composition relationship looks in code:
// Engine class
class Engine {
private int horsepower;
public Engine(int hp) { this.horsepower = hp; }
public void start() { System.out.println("Vroom!"); }
}
// Car class demonstrating Composition
public class Car {
private final Engine engine; // Final implies the car 'owns' the engine's lifecycle
public Car() {
this.engine = new Engine(200); // Created when Car is created
}
public void drive() {
engine.start();
System.out.println("Car is moving");
}
}
Design patterns are reusable solutions to common problems in software design. We begin our exploration with Creational Patterns, which handle object creation mechanisms, trying to create objects in a manner suitable to the situation.
Sometimes you need exactly one instance of a class. For example, a configuration manager, a thread pool, or a database connection pool. If multiple instances are created, it could lead to inconsistent state or wasted resources.
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. It achieves this by:
public class DatabaseConnector {
// 1. Private static instance
private static volatile DatabaseConnector instance;
// 2. Private constructor
private DatabaseConnector() {
// Initialize connection here
}
// 3. Static access method with Double-Checked Locking
public static DatabaseConnector getInstance() {
if (instance == null) {
synchronized (DatabaseConnector.class) {
if (instance == null) {
instance = new DatabaseConnector();
}
}
}
return instance;
}
public void query(String sql) {
System.out.println("Executing: " + sql);
}
}
Imagine a logistics app that initially only handles truck transport. If you want to add maritime transport, you’d have to change a lot of code everywhere you instantiate a Truck. Direct instantiation (new Truck()) couples your code to specific classes.
The Factory Method pattern defines an interface for creating an object but lets subclasses decide which class to instantiate.
from abc import ABC, abstractmethod
# Product Interface
class Transport(ABC):
@abstractmethod
def deliver(self):
pass
# Concrete Products
class Truck(Transport):
def deliver(self):
return "Delivering by land in a box."
class Ship(Transport):
def deliver(self):
return "Delivering by sea in a container."
# Creator (Factory)
class Logistics(ABC):
@abstractmethod
def create_transport(self) -> Transport:
pass
def plan_delivery(self):
transport = self.create_transport()
return f"Logistics: {transport.deliver()}"
# Concrete Creators
class RoadLogistics(Logistics):
def create_transport(self) -> Transport:
return Truck()
class SeaLogistics(Logistics):
def create_transport(self) -> Transport:
return Ship()
# Usage
logistics = RoadLogistics()
print(logistics.plan_delivery())
While the Factory Method creates one product, the Abstract Factory creates families of related products.
Imagine a UI toolkit that needs to create Buttons, Checkboxes, and Sliders. You might have a WindowsFactory and a MacFactory. Each factory produces a set of components that “match” each other.
| Pattern | Focus | Use Case |
|---|---|---|
| Singleton | Uniqueness | Resource managers, global state. |
| Factory Method | Subclassing | When exact types are determined by subclasses. |
| Abstract Factory | Families | When products must work together (themes, OS). |
In this lesson, we continue our journey through Creational Patterns by looking at the Builder and Prototype patterns. These patterns solve specific problems related to complex construction and efficient object duplication.
Imagine a Pizza class with 10+ optional parameters (size, crust, cheese, pepperoni, olives, onions, etc.). You end up with:
null or default.new Pizza(12, true, false, true, "thin", null, ...) is impossible to read or maintain.The Builder pattern separates the construction of a complex object from its representation. It allows you to produce different types and representations of an object using the same construction code.
public class Pizza {
private int size;
private String crust;
private boolean cheese;
private boolean pepperoni;
private boolean olives;
// Private constructor - only accessible via the Builder
private Pizza(Builder builder) {
this.size = builder.size;
this.crust = builder.crust;
this.cheese = builder.cheese;
this.pepperoni = builder.pepperoni;
this.olives = builder.olives;
}
public static class Builder {
private int size; // Required
private String crust = "Regular"; // Default
private boolean cheese = false;
private boolean pepperoni = false;
private boolean olives = false;
public Builder(int size) { this.size = size; }
public Builder setCrust(String crust) { this.crust = crust; return this; }
public Builder addCheese() { this.cheese = true; return this; }
public Builder addPepperoni() { this.pepperoni = true; return this; }
public Pizza build() {
return new Pizza(this);
}
}
}
// Usage
Pizza myPizza = new Pizza.Builder(12)
.setCrust("Thin")
.addCheese()
.addPepperoni()
.build();
Creating a new object from scratch can sometimes be expensive (e.g., it requires a database query or a complex calculation). If you already have an object that is similar to what you need, it might be more efficient to simply copy it.
However, standard assignment (obj1 = obj2) only copies the reference in most languages. You need a “deep copy.”
The Prototype pattern delegates the cloning process to the actual objects that are being cloned. The pattern declares a common interface for all objects that support cloning.
#include <iostream>
#include <string>
#include <memory>
class Shape {
public:
virtual ~Shape() {}
virtual std::unique_ptr<Shape> clone() const = 0;
virtual void draw() const = 0;
};
class Circle : public Shape {
int radius;
public:
Circle(int r) : radius(r) {}
// Cloning logic
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this);
}
void draw() const override {
std::cout << "Drawing Circle with radius: " << radius << std::endl;
}
};
int main() {
Circle prototypeCircle(10);
// Instead of 'new', we clone
auto clonedCircle = prototypeCircle.clone();
clonedCircle->draw();
return 0;
}
A critical concept in the Prototype pattern is how the object is cloned:
In most Prototype implementations, a Deep Copy is preferred to ensure that modifying the clone does not inadvertently change the original object.
We now move into Structural Patterns, which explain how to assemble objects and classes into larger structures while keeping these structures flexible and efficient.
You have an existing class (Legacy or 3rd-party) that provides the functionality you need, but its interface doesn’t match the one your application uses. You cannot change the legacy code. This is common when integrating new libraries into an existing codebase.
The Adapter acts as a wrapper between two objects. It catches calls for one object and transforms them to format and interface recognizable by the second object.
There are two ways to implement this:
# Target Interface (what our app expects)
class ModernUSB:
def connect_usb_c(self):
pass
# Adaptee (existing/legacy component)
class OldMemoryStick:
def plug_in_usb_a(self):
return "Connected to old USB-A port."
# Adapter
class USBAdapter(ModernUSB):
def __init__(self, old_stick: OldMemoryStick):
self.old_stick = old_stick
def connect_usb_c(self):
# Translate the call
return self.old_stick.plug_in_usb_a()
# Usage
legacy_stick = OldMemoryStick()
adapter = USBAdapter(legacy_stick)
# Our app thinks it's talking to a ModernUSB device
print(adapter.connect_usb_c())
The Adapter is often called a Wrapper. It provides a different interface to its subject.
If you have a Shape class with subclasses Circle and Square, and you want to add colors (Red and Blue), you might end up with RedCircle, BlueCircle, RedSquare, and BlueSquare. Adding a NEW shape or a NEW color results in a “Cartesian product” explosion of classes.
The Bridge pattern suggests that you extract one of the dimensions (e.g., Color) into a separate class hierarchy. Now the original class (Shape) has a reference to an object of the new hierarchy. This reference acts as a “bridge” between the Abstraction and the Implementation.
// Implementation Interface
interface Device {
void setVolume(int percent);
}
// Concrete Implementations
class TV implements Device {
public void setVolume(int p) { System.out.println("TV volume: " + p); }
}
class Radio implements Device {
public void setVolume(int p) { System.out.println("Radio volume: " + p); }
}
// Abstraction
abstract class RemoteControl {
protected Device device;
public RemoteControl(Device device) { this.device = device; }
public abstract void volumeUp();
}
// Refined Abstraction
class AdvancedRemote extends RemoteControl {
private int volume = 10;
public AdvancedRemote(Device device) { super(device); }
public void volumeUp() {
volume += 10;
device.setVolume(volume);
}
}
// Usage
RemoteControl remote = new AdvancedRemote(new TV());
remote.volumeUp(); // Bridges the remote command to the TV implementation
While they look similar, they have different intents:
| Pattern | Goal | Relationship |
|---|---|---|
| Adapter | Interface Compatibility | Wrapper around one object |
| Bridge | Decoupling | Link between two hierarchies |
By using these patterns, you can create systems that are easier to maintain because changes in one part of the system (like the implementation detail of a TV) don’t force changes in another part (the remote control logic).
Continuing our journey through Structural Patterns, we look at two patterns that help manage the relationship between objects and their compositions. While both involve wrapping or containing objects, they serve very different architectural purposes.
Imagine you are building a file system representation. You have File objects and Folder objects. Folders can contain both Files and other Folders. If you want to calculate the total size of a Folder, you have to check every item: if it’s a File, add its size; if it’s a Folder, recursively call its size method. This makes the client code complex as it must treat individual objects and groups of objects differently.
The Composite pattern allows you to compose objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly.
By defining a common interface for both the simple elements (Leaves) and the containers (Composites), the client can interact with any object in the tree without knowing its exact concrete class.
import java.util.ArrayList;
import java.util.List;
// 1. Component Interface
interface FileSystemComponent {
void showDetails();
int getSize();
}
// 2. Leaf Object
class File implements FileSystemComponent {
private String name;
private int size;
public File(String name, int size) {
this.name = name;
this.size = size;
}
public void showDetails() {
System.out.println("File: " + name + " (" + size + "KB)");
}
public int getSize() {
return size;
}
}
// 3. Composite Object
class Folder implements FileSystemComponent {
private String name;
private List<FileSystemComponent> components = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
public void addComponent(FileSystemComponent component) {
components.add(component);
}
public void showDetails() {
System.out.println("Folder: " + name);
for (FileSystemComponent component : components) {
component.showDetails();
}
}
public int getSize() {
int totalSize = 0;
for (FileSystemComponent component : components) {
totalSize += component.getSize();
}
return totalSize;
}
}
Suppose you have a Coffee class. You want to offer options like Milk, Sugar, or WhippedCream. If you use inheritance, you end up with a “class explosion”: CoffeeWithMilk, CoffeeWithSugar, CoffeeWithMilkAndSugar, etc. Static inheritance is inflexible because you cannot add behavior at runtime.
The Decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
Instead of inheriting from the base class, you “wrap” the base object inside a decorator object that implements the same interface and adds its own behavior before or after delegating to the wrapped object.
from abc import ABC, abstractmethod
# 1. Component Interface
class Coffee(ABC):
@abstractmethod
def get_cost(self):
pass
@abstractmethod
def get_description(self):
pass
# 2. Concrete Component
class SimpleCoffee(Coffee):
def get_cost(self):
return 2.0
def get_description(self):
return "Simple Coffee"
# 3. Base Decorator
class CoffeeDecorator(Coffee):
def __init__(self, coffee: Coffee):
self._decorated_coffee = coffee
def get_cost(self):
return self._decorated_coffee.get_cost()
def get_description(self):
return self._decorated_coffee.get_description()
# 4. Concrete Decorators
class MilkDecorator(CoffeeDecorator):
def get_cost(self):
return super().get_cost() + 0.5
def get_description(self):
return super().get_description() + ", Milk"
class SugarDecorator(CoffeeDecorator):
def get_cost(self):
return super().get_cost() + 0.2
def get_description(self):
return super().get_description() + ", Sugar"
# Usage
my_coffee = SimpleCoffee()
my_coffee = MilkDecorator(my_coffee)
my_coffee = SugarDecorator(my_coffee)
print(f"Order: {my_coffee.get_description()}")
print(f"Total: ${my_coffee.get_cost():.2f}")
Structural patterns provide various ways to manage relationships between classes and objects. The Facade and Proxy patterns both wrap objects, but while the Facade simplifies an interface, the Proxy controls access to an object.
Large systems often consist of dozens of classes, each with its own complex API. For a client to perform a simple task, it might need to interact with many of these classes in a specific order. This couples the client code to the internal implementation details of the subsystem, making it hard to maintain and understand.
The Facade pattern provides a simplified, higher-level interface to a complex set of classes in a subsystem. Components of the subsystem still exist and can be used directly if needed, but the Facade provides a “shortcut” for the most common use cases.
Think of it like the front desk of a hotel: you talk to the clerk (the Facade) to check in, and they handle the communication with the cleaners, the luggage handlers, and the accounting department (the Subsystem).
// Subsystem Classes
class Amplifier { void on() { System.out.println("Amp on"); } }
class DvdPlayer { void play(String movie) { System.out.println("Playing " + movie); } }
class Projector { void wideScreenMode() { System.out.println("Projector in widescreen"); } }
// The Facade
class HomeTheaterFacade {
private Amplifier amp;
private DvdPlayer dvd;
private Projector projector;
public HomeTheaterFacade(Amplifier amp, DvdPlayer dvd, Projector projector) {
this.amp = amp;
this.dvd = dvd;
this.projector = projector;
}
public void watchMovie(String movie) {
System.out.println("Get ready to watch a movie...");
projector.wideScreenMode();
amp.on();
dvd.play(movie);
}
}
// Client Code
public class Main {
public static void main(String[] args) {
HomeTheaterFacade theater = new HomeTheaterFacade(new Amplifier(), new DvdPlayer(), new Projector());
theater.watchMovie("Inception");
}
}
Sometimes an object is “expensive” to create (like a high-resolution image or a remote database connection), or you need to control who can access certain methods, or you want to log every time a method is called. You don’t want the client to deal with these complexities directly.
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. The Proxy implements the same interface as the original object (the “Real Subject”). When the client calls the Proxy, it can perform additional logic (like lazy initialization, access control, or logging) before or after forwarding the call to the Real Subject.
from abc import ABC, abstractmethod
import time
# Subject Interface
class Image(ABC):
@abstractmethod
def display(self):
pass
# Real Subject
class RealImage(Image):
def __init__(self, filename):
self.filename = filename
self._load_from_disk()
def _load_from_disk(self):
print(f"Loading {self.filename}...")
time.sleep(2) # Simulate expensive loading
def display(self):
print(f"Displaying {self.filename}")
# Proxy
class ProxyImage(Image):
def __init__(self, filename):
self.filename = filename
self.real_image = None
def display(self):
# Lazy Initialization (Virtual Proxy)
if self.real_image is None:
self.real_image = RealImage(self.filename)
self.real_image.display()
# Usage
image = ProxyImage("high_res_photo.jpg")
# The image is NOT loaded yet
print("Proxy created. Client is doing other work...")
# Image is loaded only when display() is called
image.display()
# Second call is fast because image is already loaded
image.display()
We now move into Behavioral Patterns, which deal with communication between objects and how responsibilities are assigned. These patterns help manage complex control flows and make your system more flexible to change.
Imagine a Weather Station that tracks temperature. Multiple displays (mobile app, web dashboard, physical LED screen) need to update whenever the temperature changes. If the Weather Station keeps track of every display and calls them directly, it becomes tightly coupled to those displays. Adding a new type of display would require modifying the Weather Station code.
The Observer pattern defines a one-to-many dependency between objects so that when one object (the Subject) changes state, all its dependents (Observers) are notified and updated automatically.
This decouples the Subject from its Observers. The Subject only knows that its observers implement a specific Observer interface.
import java.util.ArrayList;
import java.util.List;
// 1. Observer Interface
interface Observer {
void update(float temp);
}
// 2. Subject (Observable)
class WeatherStation {
private List<Observer> observers = new ArrayList<>();
private float temperature;
public void addObserver(Observer observer) {
observers.add(observer);
}
public void removeObserver(Observer observer) {
observers.remove(observer);
}
public void setTemperature(float temp) {
this.temperature = temp;
notifyObservers();
}
private void notifyObservers() {
for (Observer observer : observers) {
observer.update(temperature);
}
}
}
// 3. Concrete Observers
class PhoneDisplay implements Observer {
public void update(float temp) {
System.out.println("Phone Display: Temp updated to " + temp);
}
}
class WindowDisplay implements Observer {
public void update(float temp) {
System.out.println("Window Display: Temp updated to " + temp);
}
}
Suppose you have a Navigator app. It started with a Walk route algorithm. Later, you added Road and PublicTransport. If you use a single class with many if-else or switch statements to handle these different routing logics, the class becomes massive and hard to maintain.
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
Instead of the Navigator class implementing all algorithms, it delegates the work to a “strategy” object. The client can swap the strategy at runtime.
from abc import ABC, abstractmethod
# 1. Strategy Interface
class RouteStrategy(ABC):
@abstractmethod
def build_route(self, start, end):
pass
# 2. Concrete Strategies
class WalkingStrategy(RouteStrategy):
def build_route(self, start, end):
return f"Walking route from {start} to {end}: 20 mins."
class DrivingStrategy(RouteStrategy):
def build_route(self, start, end):
return f"Driving route from {start} to {end}: 5 mins (heavy traffic)."
# 3. Context
class Navigator:
def __init__(self, strategy: RouteStrategy):
self._strategy = strategy
def set_strategy(self, strategy: RouteStrategy):
self._strategy = strategy
def execute_route(self, start, end):
return self._strategy.build_route(start, end)
# Usage
nav = Navigator(WalkingStrategy())
print(nav.execute_route("Home", "Gym"))
# Change strategy at runtime
nav.set_strategy(DrivingStrategy())
print(nav.execute_route("Home", "Gym"))
Wait, isn’t Strategy similar to the State pattern? Yes, the structure is similar, but the intent is different:
Behavioral patterns allow us to manage the “how” of object interaction. The Command pattern focuses on encapsulating requests for later execution or undoing, while the State pattern focuses on how object behavior changes as its internal state evolves.
If you’re building a GUI with buttons, you might have a “Save” button and a “Copy” button. You don’t want the Button class to know exactly what logic to execute (e.g., saveDatabase() or copyToClipboard()). If you hardcode this logic into the Button class, you can’t reuse it for other purposes (like a keyboard shortcut or a menu item).
The Command pattern turns a request into a stand-alone object that contains all information about the request. This transformation lets you pass requests as method arguments, delay or queue a request’s execution, and support undoable operations.
The setup usually involves:
// 1. Command Interface
interface Command {
void execute();
void undo();
}
// 2. Receiver
class Light {
public void on() { System.out.println("Light is ON"); }
public void off() { System.out.println("Light is OFF"); }
}
// 3. Concrete Command
class LightOnCommand implements Command {
private Light light;
public LightOnCommand(Light light) { this.light = light; }
public void execute() { light.on(); }
public void undo() { light.off(); }
}
// 4. Invoker
class RemoteControl {
private Command command;
public void setCommand(Command command) { this.command = command; }
public void pressButton() { command.execute(); }
public void pressUndo() { command.undo(); }
}
Consider a Document class in an editor. It can be in one of three states: Draft, Moderation, or Published. In each state, the publish() method behaves differently:
Draft, it moves the doc to Moderation.Moderation, it makes the doc Published if the user is an admin.Published, it does nothing.
Using many if-else or switch statements for every method of the Document class leads to messy code that is hard to maintain.The State pattern lets an object alter its behavior when its internal state changes. The object will appear to change its class.
The key is to extract state-specific behaviors into separate “State” classes and delegate the work to the current state object.
from abc import ABC, abstractmethod
# 1. State Interface
class State(ABC):
@abstractmethod
def publish(self, document):
pass
# 2. Concrete States
class DraftState(State):
def publish(self, document):
print("Moving document from Draft to Moderation.")
document.set_state(ModerationState())
class ModerationState(State):
def publish(self, document):
print("Reviewing document... Approved! Moving to Published.")
document.set_state(PublishedState())
class PublishedState(State):
def publish(self, document):
print("Document is already published. Doing nothing.")
# 3. Context (Document)
class Document:
def __init__(self):
self._state = DraftState()
def set_state(self, state: State):
self._state = state
def publish(self):
self._state.publish(self)
# Usage
doc = Document()
doc.publish() # Moves to Moderation
doc.publish() # Moves to Published
doc.publish() # Already published
While the class diagrams look identical (a Context class holding a reference to an Interface), the intent is different:
Our final look at Behavioral Patterns covers two patterns that optimize how we structure algorithms and how we traverse data. Both patterns are heavily used in modern frameworks and standard libraries.
Imagine you are writing a data mining application that extracts information from various document formats (PDF, DOC, CSV). Most of the steps are identical: open the file, extract data, parse data, analyze data, and close the file. However, the extraction and parsing logic differs for each format. If you duplicate the skeleton logic in every class, you’ll have a maintenance nightmare.
The Template Method pattern defines the skeleton of an algorithm in a method, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.
The base class defines “hooks” or abstract methods that subclasses must implement, while the “template method” itself is usually final (in Java) or intended not to be overridden.
abstract class DataMiner {
// 1. The Template Method
public final void mine(String path) {
openFile(path);
extractData();
parseData();
analyzeData();
closeFile();
}
// 2. Concrete steps (same for all)
private void analyzeData() { System.out.println("Analyzing data..."); }
private void closeFile() { System.out.println("Closing file."); }
// 3. Abstract steps (different for each)
protected abstract void openFile(String path);
protected abstract void extractData();
protected abstract void parseData();
}
class PdfDataMiner extends DataMiner {
protected void openFile(String path) { System.out.println("Opening PDF: " + path); }
protected void extractData() { System.out.println("Extracting text from PDF..."); }
protected void parseData() { System.out.println("Parsing PDF structure..."); }
}
A collection is just a container for a group of objects. It could be a List, a Stack, a Tree, or a Graph. If you want to traverse these collections, you need various methods. A Tree requires Depth-First or Breadth-First traversal. If the client code has to handle these different traversal logics, it becomes tightly coupled to the internal structure of the collection.
The Iterator pattern provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation (List, Stack, Tree, etc.).
By implementing an Iterator interface, different collections can provide their own iterators, allowing the client to traverse them using the same consistent API (e.g., hasNext() and next()).
from collections.abc import Iterable, Iterator
# Concrete Iterator
class AlphabeticalOrderIterator(Iterator):
def __init__(self, collection, reverse: bool = False):
self._collection = collection
self._reverse = reverse
self._position = -1 if reverse else 0
def __next__(self):
try:
value = self._collection[self._position]
self._position += -1 if self._reverse else 1
except IndexError:
raise StopIteration()
return value
# Concrete Collection
class WordCollection(Iterable):
def __init__(self, collection = []):
self._collection = collection
def __iter__(self) -> AlphabeticalOrderIterator:
return AlphabeticalOrderIterator(self._collection)
def get_reverse_iterator(self) -> AlphabeticalOrderIterator:
return AlphabeticalOrderIterator(self._collection, True)
def add_item(self, item):
self._collection.append(item)
# Usage
words = WordCollection()
words.add_item("First")
words.add_item("Second")
words.add_item("Third")
print("Straight traversal:")
for item in words:
print(item)
print("\nReverse traversal:")
for item in words.get_reverse_iterator():
print(item)
Most modern languages (Java, Python, C#, JavaScript) have built-in support for the Iterator pattern. In Java, it’s the Iterator<T> interface and foreach loops. In Python, it’s the __iter__ and __next__ methods.
Software architecture refers to the high-level structures of a software system, the discipline of creating such structures, and the documentation of these structures. It is the blueprint of the system and provides an abstraction to manage the complexity and establish a communication and coordination mechanism among components.
Architecture is about the significant decisions—decisions that are expensive to change. It defines how the system is partitioned into components, how those components interact (interfaces), and the constraints under which they operate.
SoC is a design principle for separating a computer program into distinct sections such that each section addresses a separate concern. A concern is a set of information that affects the code of a computer program.
A component should have limited knowledge about other units: only units “closely” related to the current unit. In terms of code, an object should only call methods on itself, its fields, or its parameters.
Dependencies should point towards higher-level policies and abstractions, not towards low-level details (like databases or UIs). This is closely related to the Dependency Inversion Principle (DIP).
Architectural patterns provide a template for solving recurring design problems in a particular context.
This pattern organizes the system into layers, where each layer provides services to the layer above it and uses services from the layer below. Typical layers include Presentation, Business, and Data Access.
An architectural style that structures an application as a collection of services that are highly maintainable, testable, loosely coupled, and independently deployable.
In EDA, components communicate by producing and consuming events. This allows for high scalability and decoupling between the producer and consumer.
In modern distributed systems, architecture must account for failure as a first-class citizen and ensure the system can handle growth.
To prevent a failure in one service from cascading through the entire system, architects use resiliency patterns:
Architecture is judged by its ability to satisfy quality attributes, often called non-functional requirements:
// Logic Layer (Service)
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void processOrder(Order order) {
// Business logic here
repository.save(order);
}
}
// Data Access Layer (Repository)
public interface OrderRepository {
void save(Order order);
}
In this example, the OrderService depends on an abstraction (OrderRepository), following the principle of Dependency Inversion and Separation of Concerns.
Unit testing is a software testing method where individual units or components of a software are tested. The purpose is to validate that each unit of the software code performs as expected. A unit is the smallest testable part of any software; it usually has one or a few inputs and usually a single output.
Good unit tests follow the FIRST acronym:
A common structure for unit tests is Arrange, Act, Assert:
When a unit has dependencies (like a database or a web service), we use test doubles to isolate the unit:
Code coverage measures the percentage of code executed during the test suite. Common metrics include:
Crucial Note: 100% code coverage does not mean 100% of the logic is correct or that the code is well-tested. It only means the code was executed.
Mutation testing is a more advanced technique where the testing tool automatically makes small changes (mutations) to the production code (e.g., changing a > to a <). If the test suite still passes after a mutation, the test is “weak” because it failed to catch the regression.
Consider a simple calculator class:
# calculator.py
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
The corresponding unit test using pytest:
# test_calculator.py
import pytest
from calculator import Calculator
def test_add():
# Arrange
calc = Calculator()
# Act
result = calc.add(2, 3)
# Assert
assert result == 5
def test_divide():
calc = Calculator()
assert calc.divide(10, 2) == 5
def test_divide_by_zero():
calc = Calculator()
with pytest.raises(ValueError, match="Cannot divide by zero"):
calc.divide(10, 0)
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class CalculatorTest {
@Test
void testAdd() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3), "2 + 3 should equal 5");
}
@Test
void testException() {
Calculator calc = new Calculator();
assertThrows(IllegalArgumentException.class, () -> {
calc.divide(1, 0);
});
}
}
By adhering to these principles, unit tests become a powerful tool for ensuring code quality and long-term maintainability.
Test-Driven Development (TDD) is a software development process where you write a test before you write the actual production code. It is a reversal of the traditional development process. TDD is not just a testing technique; it is a design process that ensures your code is testable, modular, and fulfills requirements.
The core of TDD is a simple repetitive cycle:
Robert C. Martin (Uncle Bob) popularized three rules for TDD:
TDD is most effective when applied at the base of the “Testing Pyramid.” The pyramid suggests:
By focusing TDD on unit tests, you build a solid foundation of correctness that allows you to move faster.
ATDD is an extension of TDD where the entire team (Business, Dev, QA) collaborates to define acceptance tests before development starts. These tests are written from the perspective of the user and define the “Definition of Done.” ATDD aligns closely with BDD (Behavior-Driven Development).
Imagine we are building a FizzBuzz function.
// fizzbuzz.test.ts
import { fizzBuzz } from './fizzbuzz';
test('returns "1" for 1', () => {
expect(fizzBuzz(1)).toBe("1");
});
This fails because fizzBuzz is not defined.
// fizzbuzz.ts
export function fizzBuzz(n: number): string {
return "1"; // Minimal code to pass
}
The test passes.
test('returns "Fizz" for 3', () => {
expect(fizzBuzz(3)).toBe("Fizz");
});
This fails because it returns “1”.
export function fizzBuzz(n: number): string {
if (n === 3) return "Fizz";
return "1";
}
As we add more tests (for 5, 15, etc.), the implementation will grow.
export function fizzBuzz(n: number): string {
if (n % 15 === 0) return "FizzBuzz";
if (n % 3 === 0) return "Fizz";
if (n % 5 === 0) return "Buzz";
return n.toString();
}
By following TDD, we ensure that every line of code is justified by a test case.
Behavior-Driven Development (BDD) is an agile software development process that encourages collaboration among developers, QA, and non-technical or business participants in a software project. It evolved from Test-Driven Development (TDD) by focusing on the behavior of the system from the user’s perspective.
While TDD focuses on how a unit of code works, BDD focuses on what the system does for the user. It uses a domain-specific language (DSL) that is readable by both technical and non-technical stakeholders.
BDD is as much about communication as it is about automation. The “Three Amigos” is a practice where three key perspectives meet to discuss a requirement before it is implemented:
This collaboration results in a shared understanding and a set of concrete examples that form the basis of the Gherkin scenarios.
BDD is often implemented through Specification by Example. Instead of abstract requirements, the team uses concrete examples of the system in action.
BDD scenarios are often written in Gherkin, a plain-text language that follows a specific structure:
Feature: User Login
As a registered user
I want to log into my account
So that I can access my personalized dashboard
Scenario: Successful login with valid credentials
Given I am on the login page
When I enter "john.doe@example.com" in the email field
And I enter "password123" in the password field
And I click the "Login" button
Then I should be redirected to the dashboard
And I should see a welcome message "Welcome, John!"
The scenarios in Gherkin are mapped to “Step Definitions” in code.
const { Given, When, Then } = require('@cucumber/cucumber');
const assert = require('assert');
Given('I am on the login page', async function () {
await browser.url('/login');
});
When('I enter {string} in the email field', async function (email) {
await browser.setValue('#email', email);
});
When('I enter {string} in the password field', async function (password) {
await browser.setValue('#password', password);
});
When('I click the {string} button', async function (buttonName) {
await browser.click('#login-btn');
});
Then('I should be redirected to the dashboard', async function () {
const url = await browser.getUrl();
assert.strictEqual(url.includes('/dashboard'), true);
});
| Feature | TDD | BDD |
|---|---|---|
| Audience | Developers | Developers, QA, Business Stakeholders |
| Language | Programming Language (JUnit, etc.) | Domain-Specific Language (Gherkin) |
| Focus | Unit implementation / Code quality | System behavior / User requirements |
| Granularity | Method/Class level | Feature/User Story level |
BDD doesn’t replace TDD; they are complementary. TDD ensures the code is built right, while BDD ensures the right system is built.
Integration testing is the phase in software testing where individual software modules are combined and tested as a group. It occurs after unit testing and before system testing. The goal is to uncover faults in the interaction between integrated units.
Even if every unit works perfectly in isolation (unit tests), the system can still fail because:
Everything is integrated at once, and then the entire system is tested. While simple, it makes debugging extremely difficult because failure can be anywhere.
Testing begins from the top-level modules (e.g., UI) and moves down to low-level modules. Missing low-level modules are replaced with stubs.
Testing starts from the lowest-level modules and moves up. Higher-level modules are replaced with drivers.
A combination of Top-Down and Bottom-Up approaches.
In microservice architectures, integration tests can become very slow and brittle. Contract Testing (e.g., using Pact) allows you to test the interface between services without needing both services running at the same time.
While the “Testing Pyramid” emphasizes unit tests, some modern developers (like Kent C. Dodds) advocate for the Testing Trophy. The trophy emphasizes Integration Tests as the “sweet spot” of testing—providing the best balance between speed, confidence, and cost. It suggests that since many bugs occur at the integration points, that is where the bulk of the testing effort should go.
Modern integration testing often uses tools like Testcontainers to spin up real databases or message brokers in Docker containers during the test run.
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alphine");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveAndRetrieveOrder() {
// Arrange
Order order = new Order("item-123", 2);
// Act
orderRepository.save(order);
List<Order> orders = orderRepository.findAll();
// Assert
assertEquals(1, orders.size());
assertEquals("item-123", orders.get(0).getItemId());
}
}
Testing how your application interacts with RESTful APIs is a common form of integration testing.
const request = require('supertest');
const app = require('../src/app');
describe('POST /api/orders', () => {
it('should create a new order and return 201', async () => {
const res = await request(app)
.post('/api/orders')
.send({
itemId: 'SKU-001',
quantity: 5
});
expect(res.statusCode).toEqual(201);
expect(res.body).toHaveProperty('id');
expect(res.body.itemId).toBe('SKU-001');
});
});
Integration testing is crucial for ensuring that the various “cogs” of your software machine work together smoothly, preventing costly “integration hell” late in the development cycle.
In modular software systems, components rarely exist in a vacuum. They depend on database connections, external APIs, file systems, or other complex services. When writing unit tests, we want to test a single unit in isolation. If that unit calls a real database, it’s no longer a unit test—it’s an integration test.
To maintain isolation, speed, and reliability, we use Test Doubles.
Gerard Meszaros defined several types of test doubles in his book xUnit Test Patterns:
While often used interchangeably, there is a fundamental difference in their verification style:
Mockito is the most popular framework for mocking in the Java ecosystem.
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class OrderServiceTest {
@Test
void testCheckoutProcessesPayment() {
// 1. Arrange (Create Mocks)
PaymentProcessor mockPayment = mock(PaymentProcessor.class);
InventoryService mockInventory = mock(InventoryService.class);
// Stubbing: Define behavior
when(mockInventory.isAvailable("SKU-123")).thenReturn(true);
OrderService service = new OrderService(mockPayment, mockInventory);
Order order = new Order("SKU-123", 100.00);
// 2. Act
service.checkout(order);
// 3. Assert / Verify
// Verify behavior: Was the payment processed exactly once?
verify(mockPayment, times(1)).process(100.00);
// Verify with Argument Captor
ArgumentCaptor<Double> captor = ArgumentCaptor.forClass(Double.class);
verify(mockPayment).process(captor.capture());
assertEquals(100.00, captor.getValue());
}
}
unittest.mockPython includes a powerful mocking library in the standard library.
from unittest.mock import MagicMock, patch
import unittest
class TestUserService(unittest.TestCase):
def test_get_user_name_from_db(self):
# Create a mock database connection
mock_db = MagicMock()
# Stub the return value of a method
mock_db.get_user.return_value = {"id": 1, "name": "Alice"}
# Inject the mock
user_service = UserService(database=mock_db)
name = user_service.get_user_primary_name(1)
# Assertions
self.assertEqual(name, "Alice")
# Verification: Check if get_user was called with correct ID
mock_db.get_user.assert_called_once_with(1)
@patch('requests.get')
def test_api_call(self, mock_get):
# Setup mock response
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"status": "ok"}
client = MyApiClient()
response = client.fetch_data()
self.assertEqual(response["status"], "ok")
mock_get.assert_called_once()
when(...).thenReturn(...) just to test a 2-line function, your code might be too tightly coupled or the unit too large.”Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” — Martin Fowler.
Clean code is not about a rigid set of rules, but about a mindset. It is code that looks like it was written by someone who cares.
Names should reveal intent. Avoid generic names like data, info, or item.
MAX_RETRY_ATTEMPTS is better than 7.strName) or member prefixes (m_name). Modern IDEs handle types and scope.postPayment(), deletePage(), or save().get, set, and is (for booleans).Functions should be small and do one thing.
”Comments are always failures… because we cannot always write code that is clear enough.” — Robert C. Martin.
// This takes 30 seconds to run).// Function to handle users
function handle(u, list) {
for (let i = 0; i < list.length; i++) {
if (list[i].status === 'active') {
if (list[i].age > 18) {
// Send email
smtp.send(list[i].email, "Hello " + u.name);
}
}
}
}
/**
* Notifies all active adult users about the new update.
*/
function notifyAdultSubscribers(users) {
const activeAdults = users.filter(isAdult).filter(isActive);
activeAdults.forEach(sendWelcomeEmail);
}
function isAdult(user) {
return user.age >= 18;
}
function isActive(user) {
return user.status === 'active';
}
function sendWelcomeEmail(user) {
const subject = `Hello ${user.name}`;
emailService.send(user.email, subject);
}
null forces a null check on every caller. Return an empty collection, an Optional, or use the Null Object Pattern.Refactoring is the process of restructuring existing computer code—changing the factoring—without changing its external behavior. It is essentially “cleaning up” code to make it more maintainable and understandable.
A “code smell” is a surface indication that usually corresponds to a deeper problem in the system.
If you have a code fragment that can be grouped together, turn the fragment into a method whose name explains the purpose of the method.
Before:
void printOwing() {
printBanner();
// Print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}
After:
void printOwing() {
printBanner();
printDetails(getOutstanding());
}
void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}
If you have a conditional (switch or if-else) that performs different behaviors depending on the type of an object, use subclasses and polymorphism instead.
Before:
def get_speed(bird):
if bird.type == "EUROPEAN":
return base_speed()
elif bird.type == "AFRICAN":
return base_speed() - load_factor() * bird.num_coconuts
elif bird.type == "NORWEGIAN_BLUE":
return 0 if bird.is_nailed else base_speed()
After:
class Bird:
def get_speed(self): pass
class European(Bird):
def get_speed(self): return base_speed()
class African(Bird):
def get_speed(self): return base_speed() - load_factor() * self.num_coconuts
class NorwegianBlue(Bird):
def get_speed(self): return 0 if self.is_nailed else base_speed()
If a method is used more by another class than the one it’s in, move it to the other class. This reduces Feature Envy.
Replace a temporary variable used to store an expression with a method. This makes the logic reusable throughout the class.
Before:
let basePrice = quantity * itemPrice;
if (basePrice > 1000) return basePrice * 0.95;
else return basePrice * 0.98;
After:
if (basePrice() > 1000) return basePrice() * 0.95;
else return basePrice() * 0.98;
function basePrice() {
return quantity * itemPrice;
}
In modern software engineering, the boundary between “development” and “operations” has blurred. DevOps is a set of practices, tools, and a cultural philosophy that automate and integrate the processes between software development and IT teams.
CI is the practice of merging all developer working copies to a shared mainline several times a day.
Continuous Delivery is an extension of CI to ensure that you can release new changes to your customers quickly in a sustainable way.
Every change that passes all stages of your production pipeline is released to your customers. There is no human intervention.
A typical pipeline consists of several stages:
Triggered by a code change in a repository (e.g., GitHub, GitLab, Bitbucket).
The application is compiled, and artifacts (like JAR files, Docker images) are created.
Artifacts are deployed to environments (Staging, then Production). Use techniques like Blue-Green Deployment or Canary Releases to minimize risk.
GitHub Actions allows you to define your CI/CD pipeline in a .yaml file within your repository.
# .github/workflows/ci.yml
name: Java CI with Maven
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Build and Test with Maven
run: mvn -B package --file pom.xml
- name: Run Static Analysis
run: mvn sonar:sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: app-jar
path: target/*.jar
DevOps often involves managing infrastructure through code (e.g., Terraform, CloudFormation, Ansible). This allows you to version control your server configurations just like your application code.
Software engineering doesn’t end when the code is deployed. In fact, for most successful systems, the Maintenance phase accounts for 60-80% of the total software lifecycle cost.
Quality is not just “lack of bugs.” The ISO 25010 standard defines a quality model with several key attributes:
Technical debt is a metaphor for the eventual consequences of poor system design, software architecture, or software development within a codebase.
A legacy system is an old method, technology, computer system, or application program that is still in use.
Congratulations on completing the Software Engineering & Object-Oriented Analysis and Design course!
Over the past 30 lessons, we have traveled from the basic building blocks of objects and classes to the complex ecosystems of DevOps and software maintenance.
Happy Coding!