The Object-Oriented Design Handbook: SOLID, GRASP, and the Principles That Make Software Scale

3
calendar_todayschedule20 min read

Imagine walking into a very busy factory that produces a highly in-demand product. This factory is packed with workers from different parts of the project, coming together to get it delivered. And because it's a very large factory, there can be up to 100 workers at the same time, channeling their energies into different tasks.

But the amazing thing is, these workers aren't running into each other, mixing tasks up, and causing confusion and conflicts. Instead, everyone has a clear role, pattern, methodology, skillset, and defined responsibilities, as well as a structured way of interacting with each other.

No one person does everything, and everyone does their own thing (the right thing). Because of this, the system works productively, is scalable, and delivers the product in a clearly defined and structured manner.

This is what building software looks like when it is properly designed, following strategically laid-down rules and principles.

Object-Oriented Design is the engineering equivalent of running this factory with the discipline of breaking complexities into clear, well-defined objects where each of them have defined purposes, boundaries, and rules for interaction.

This is more about designing a system that does not collapse as it grows.

OOP techniques create a huge difference between an app that becomes impossible to maintain after some time and an app that scales gracefully as features, users, and complexities increase.

Many developers would think of Object-Oriented Design as classes, objects, constructors, and maybe inheritance, but OOP transcends more than just classes. It is about designing relationships.

So technically, Object-Oriented Design is the core discipline of breaking a large or medium application into small and focused objects that each does one thing well.

While following this design, you would think of things like:

What objects should exist?

What modifiers should be used to create this object or class?

What responsibilities should a class have?

How should objects behave and communicate with each other?

How can I prevent an object from being too exposed or doing too much?

All of these questions and many more strategic ones help fine-tune your application towards achieving the productivity and scalability Object-Oriented Design offers.

Having understood this, there are core principles that make a fantastic design possible. These rules keep your objects clean, focused, reusable, scalable, and interactive following the actual use cases they are meant to fulfill.

This handbook breaks the principles into 7 major slices:

SOLID Principles — The foundation of robust object design

GRASP Principles — General Responsibility Assignment patterns

Package Principles — Applying SOLID to modular packages

Class Design Principles — Best practices for designing classes

Design Pattern Principles — Reusable solutions to common design problems

Error Handling Principles — Structuring failures without breaking the system

Testing Principles — Ensuring your objects behave as expected

The aim is to understand all the principles that make the difference between a system that collapses under complexity and one that scales gracefully as features, users, and requirements grow.

Table of Contents

SOLID Principles: The Foundation of Robust Object Design

GRASP Principles: General Responsibility Assignment Patterns

Package Principles: Applying SOLID to Modular Packages

Class Design Principles: Best Practices for Designing Classes

Design Pattern Principles: Reusable Solutions to Common Problems

Error Handling Principles: Structuring Failures Without Breaking the System

Testing Principles: Ensuring Your Objects Behave as Expected

Conclusion

SOLID Principles: The Foundation of Robust Object Design

SOLID is an acronym coined by Robert C. Martin (Uncle Bob) representing five fundamental principles that form the bedrock of object-oriented design. Think of SOLID as the factory's core operating rules: the non-negotiables that keep everything running smoothly.

These five principles work together to ensure your code is:

Maintainable: Easy to modify without breaking existing functionality

Scalable: Handles growth in features and complexity

Testable: Each component can be tested in isolation

Flexible: Adapts to changing requirements without major rewrites

  1. Single Responsibility Principle (SRP)

Definition: A class should have one, and only one, reason to change.

Factory Analogy: Imagine a factory worker who assembles engines, packages products, does quality checks, AND manages inventory. When any of these processes change, this one worker must adapt. This creates confusion, errors, and bottlenecks.

Instead, assign ONE clear job per worker:

Worker A: Assembles engines

Worker B: Packages products

Worker C: Quality inspection

Worker D: Inventory management

Now when packaging requirements change, only Worker B adapts. The system stays stable.

Code Example - Violating SRP:

// ❌ BAD: This class has multiple responsibilities
class UserManager {
// Responsibility 1: User validation
bool validateEmail(String email) {

return email.contains('@');

}

// Responsibility 2: Database operations
Future saveUser(User user) async {

await database.insert('users', user.toJson());

}

// Responsibility 3: Email notifications
Future sendWelcomeEmail(User user) async {

await emailService.send(user.email, 'Welcome!');

}

// Responsibility 4: Logging
void logUserCreation(User user) {

logger.log('User created: ${user.name}');

}
}

// Problem: If email validation rules change, OR database schema changes,
// OR email template changes, OR logging format changes — you modify the SAME class.
// This violates SRP.

Code Example - Following SRP:

// ✅ GOOD: Each class has ONE responsibility

class UserValidator {
bool validateEmail(String email) {

return email.contains('@') && email.length > 5;

}

bool validatePassword(String password) {

return password.length >= 8;

}
}

class UserRepository {
Future saveUser(User user) async {

await database.insert('users', user.toJson());

}

Future<User?> findUserById(String id) async {

final data = await database.query('users', where: 'id = ?', args: [id]);
return data != null ? User.fromJson(data) : null;

}
}

class UserNotificationService {
Future sendWelcomeEmail(User user) async {

await emailService.send(user.email, 'Welcome to our platform!');

}
}

class UserLogger {
void logUserCreation(User user) {

logger.info('User created: ${user.name} at ${DateTime.now()}');

}
}

// Orchestrate these focused classes in a UseCase
class CreateUserUseCase {
final UserValidator validator;
final UserRepository repository;
final UserNotificationService notificationService;
final UserLogger logger;

CreateUserUseCase({

required this.validator,
required this.repository,
required this.notificationService,
required this.logger,

});

Future execute(User user) async {

if (!validator.validateEmail(user.email)) {
  throw ValidationException('Invalid email');
}
await repository.saveUser(user);
await notificationService.sendWelcomeEmail(user);
logger.logUserCreation(user);

}
}

Benefits:

Each class has ONE reason to change

Easy to test in isolation

Easy to reuse (UserValidator can be used elsewhere)

Clear boundaries and responsibilities

When to Apply:

When a class is doing more than one thing

When changes in one area force changes in unrelated areas

When testing requires mocking multiple unrelated dependencies

  1. Open/Closed Principle (OCP)

Definition: Software entities should be open for extension, but closed for modification.

Factory Analogy: Your factory produces cars. Now management wants to add truck production.

Bad approach: Modify the existing car assembly line (risky, might break car production). Good approach: Add a new truck assembly line (extends capability without touching the car line).

Code Example - Violating OCP:

// ❌ BAD: Must modify class to add new payment methods
class PaymentProcessor {
void processPayment(String paymentType, double amount) {

if (paymentType == 'credit_card') {
  print('Processing credit card payment: \$${amount}');
} else if (paymentType == 'paypal') {
  print('Processing PayPal payment: \$${amount}');
} else if (paymentType == 'crypto') {
  print('Processing crypto payment: \$${amount}');
}
// Adding Apple Pay? Must modify this class and add another if-else.

}
}

Code Example - Following OCP:

// ✅ GOOD: Open for extension, closed for modification

abstract class PaymentMethod {
Future process(double amount);
String get name;
}

class CreditCardPayment extends PaymentMethod {
@override
String get name => 'Credit Card';

@override
Future process(double amount) async {

print('Processing credit card: \$${amount}');

}
}

class PayPalPayment extends PaymentMethod {
@override
String get name => 'PayPal';

@override
Future process(double amount) async {

print('Processing PayPal: \$${amount}');

}
}

// New payment method added WITHOUT modifying PaymentProcessor
class ApplePayPayment extends PaymentMethod {
@override
String get name => 'Apple Pay';

@override
Future process(double amount) async {

print('Processing Apple Pay: \$${amount}');

}
}

class PaymentProcessor {
Future processPayment(PaymentMethod paymentMethod, double amount) async {

print('Processing ${paymentMethod.name} payment...');
await paymentMethod.process(amount);
print('Payment completed!');

}
}

Benefits:

Add new features without touching existing code

Reduces risk of breaking working functionality

Easier testing (new payment methods tested independently)

Scalable architecture

When to Apply:

When you anticipate new variations of behavior

When you have if-else or switch statements based on types

When adding features requires modifying existing classes

  1. Liskov Substitution Principle (LSP)

Definition: Objects of a subclass should be replaceable with objects of the parent class without breaking the application.

In simpler terms: if class B extends class A, anywhere you use A, you should be able to use B and the program should still work correctly.

Factory Analogy: Your factory has a standard packaging machine. You introduce a new upgraded packaging machine that does everything the old one does, plus more. Any production line that used the old machine can switch to the new one without any changes to the line itself. If the new machine broke the production line, it would violate LSP.

Code Example - Violating LSP:

// ❌ BAD: Subclass breaks the expected behavior of the parent

class Bird {
void fly() {

print('Flying...');

}
}

class Penguin extends Bird {
@override
void fly() {

// Penguins cannot fly — this throws an exception!
throw UnsupportedError('Penguins cannot fly');

}
}

void makeBirdFly(Bird bird) {
bird.fly(); // This will crash when bird is a Penguin
}

// Usage
makeBirdFly(Bird()); // Works fine
makeBirdFly(Penguin()); // Runtime crash — LSP violated

The problem is clear. Penguin cannot honor the fly() contract that Bird promises. Substituting Penguin for Bird breaks the program.

Code Example - Following LSP:

// ✅ GOOD: Proper hierarchy that respects behavioral contracts

abstract class Bird {
void eat();
void sleep();
String get name;
}

abstract class FlyingBird extends Bird {
void fly();
}

abstract class SwimmingBird extends Bird {
void swim();
}

class Eagle extends FlyingBird {
@override
String get name => 'Eagle';

@override
void fly() => print('Eagle soaring high');

@override
void eat() => print('Eagle eating fish');

@override
void sleep() => print('Eagle sleeping in nest');
}

class Penguin extends SwimmingBird {
@override
String get name => 'Penguin';

@override
void swim() => print('Penguin swimming gracefully');

@override
void eat() => print('Penguin eating krill');

@override
void sleep() => print('Penguin sleeping on ice');
}

// Each function works with the correct abstraction
void makeBirdFly(FlyingBird bird) => bird.fly();
void makeBirdSwim(SwimmingBird bird) => bird.swim();

// Usage
makeBirdFly(Eagle()); // Works perfectly
makeBirdSwim(Penguin()); // Works perfectly

A Real-World Example in Dart:

// ❌ BAD: ReadOnlyRepository violates the Repository contract
abstract class Repository {
Future<T?> findById(String id);
Future save(T entity);
Future delete(String id);
}

class ReadOnlyUserRepository extends Repository {
@override
Future<User?> findById(String id) async {

return await database.find(id);

}

@override
Future save(User entity) async {

throw UnsupportedError('This repository is read-only'); // LSP violation

}

@override
Future delete(String id) async {

throw UnsupportedError('This repository is read-only'); // LSP violation

}
}

// ✅ GOOD: Separate abstractions for separate capabilities
abstract class ReadableRepository {
Future<T?> findById(String id);
Future<List> findAll();
}

abstract class WritableRepository extends ReadableRepository {
Future save(T entity);
Future delete(String id);
}

class UserRepository extends WritableRepository {
@override
Future<User?> findById(String id) async => await database.find(id);

@override
Future<List> findAll() async => await database.findAll();

@override
Future save(User entity) async => await database.insert(entity);

@override
Future delete(String id) async => await database.delete(id);
}

class AuditLogRepository extends ReadableRepository {
@override
Future<AuditLog?> findById(String id) async => await database.find(id);

@override
Future<List> findAll() async => await database.findAll();
// No save or delete — audit logs are immutable by design
}

Benefits:

Predictable behavior when using polymorphism

Safer use of inheritance

Code that uses base types continues to work correctly with any subtype

Fewer runtime surprises

When to Apply:

Before creating any subclass — ask: "Can this subclass honor every contract the parent promises?"

When a subclass needs to throw UnsupportedError for inherited methods

When overriding a method in a way that weakens the expected behavior

  1. Interface Segregation Principle (ISP)

Definition: A class should not be forced to implement interfaces it does not use. Clients should depend only on the methods they actually need.

In other words, prefer many small, focused interfaces over one large, general-purpose interface.

Factory Analogy: Imagine a job description that requires every worker to assemble products, operate heavy machinery, drive a forklift, manage accounts, AND handle customer service. A worker hired for packaging should not be required to have a forklift license. Each role should have the specific skills it actually needs.

Code Example - Violating ISP:

// ❌ BAD: One fat interface forces implementations to stub out unused methods
abstract class Worker {
void work();
void eat();
void sleep();
void attendMeeting();
void writeReport();
void operateMachinery();
}

// A robot worker should not be forced to implement eat, sleep, or attendMeeting
class RobotWorker implements Worker {
@override
void work() => print('Robot working 24/7');

@override
void eat() => throw UnsupportedError('Robots do not eat'); // Forced stub

@override
void sleep() => throw UnsupportedError('Robots do not sleep'); // Forced stub

@override
void attendMeeting() => throw UnsupportedError('Robots do not attend meetings');

@override
void writeReport() => throw UnsupportedError('Robots do not write reports');

@override
void operateMachinery() => print('Robot operating machinery');
}

Code Example - Following ISP:

// ✅ GOOD: Small, focused interfaces

abstract class Workable {
void work();
}

abstract class Eatable {
void eat();
}

abstract class Sleepable {
void sleep();
}

abstract class MeetingAttendable {
void attendMeeting();
}

abstract class MachineryOperable {
void operateMachinery();
}

// Human worker implements only what humans do
class HumanWorker implements Workable, Eatable, Sleepable, MeetingAttendable {
@override
void work() => print('Human working with focus');

@override
void eat() => print('Human eating lunch');

@override
void sleep() => print('Human sleeping 8 hours');

@override
void attendMeeting() => print('Human attending standup');
}

// Robot worker implements only what robots do
class RobotWorker implements Workable, MachineryOperable {
@override
void work() => print('Robot working 24/7');

@override
void operateMachinery() => print('Robot operating machinery');
}

A Real-World Flutter Example:

// ❌ BAD: Fat repository interface forces every implementation to support all operations
abstract class UserRepository {
Future<User?> findById(String id);
Future save(User user);
Future delete(String id);
Future exportToCsv();
Future sendBulkEmail(List users);
Future<Map<String, dynamic>> generateReport();
}

// ✅ GOOD: Each interface has a single, focused purpose
abstract class UserReadRepository {
Future<User?> findById(String id);
Future<List> findAll();
}

abstract class UserWriteRepository {
Future save(User user);
Future delete(String id);
}

abstract class UserExportRepository {
Future exportToCsv();
}

abstract class UserCommunicationRepository {
Future sendBulkEmail(List users);
}

// Implementations can mix and match what they support
class SqlUserRepository implements UserReadRepository, UserWriteRepository {
@override
Future<User?> findById(String id) async => await db.find(id);

@override
Future<List> findAll() async => await db.findAll();

@override
Future save(User user) async => await db.insert(user);

@override
Future delete(String id) async => await db.delete(id);
}

Benefits:

No class is forced to implement methods it does not need

Smaller interfaces are easier to implement and test

Changes to one interface do not affect unrelated implementations

More focused and meaningful contracts

When to Apply:

When an interface has methods that some implementations must stub out or throw for

When adding a new method to an interface would force changes in unrelated classes

When an interface is doing too many different things

  1. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

In practical terms: depend on interfaces and abstract classes, not on concrete implementations.

Factory Analogy: The factory manager (high-level) should not directly operate a specific brand of machine (low-level). Instead, the manager issues instructions to a machine operator interface. Any brand of machine that fulfills the operator interface can be plugged in. The manager never changes when the machine brand changes.

Code Example - Violating DIP:

// ❌ BAD: High-level class directly depends on a concrete low-level class

class MySqlDatabase {
Future insert(String table, Map<String, dynamic> data) async {

// MySQL-specific implementation
print('Inserting into MySQL: $table');

}

Future<Map<String, dynamic>?> query(String table, String id) async {

// MySQL-specific implementation
print('Querying MySQL: $table with id $id');
return null;

}
}

// UserService directly depends on MySqlDatabase
// If you ever switch to PostgreSQL or Hive, UserService must change
class UserService {
final MySqlDatabase _database = MySqlDatabase(); // concrete dependency

Future createUser(User user) async {

await _database.insert('users', user.toJson());

}
}

Code Example - Following DIP:

// ✅ GOOD: Both high-level and low-level depend on abstraction

// The abstraction
abstract class Database {
Future insert(String table, Map<String, dynamic> data);
Future<Map<String, dynamic>?> query(String table, String id);
Future delete(String table, String id);
}

// Low-level module: concrete implementation
class MySqlDatabase implements Database {
@override
Future insert(String table, Map<String, dynamic> data) async {

print('MySQL: Inserting into $table');

}

@override
Future<Map<String, dynamic>?> query(String table, String id) async {

print('MySQL: Querying $table with id $id');
return null;

}

@override
Future delete(String table, String id) async {

print('MySQL: Deleting from $table with id $id');

}
}

// Another implementation — swap with zero changes to UserService
class HiveDatabase implements Database {
@override
Future insert(String table, Map<String, dynamic> data) async {

print('Hive: Inserting into $table');

}

@override
Future<Map<String, dynamic>?> query(String table, String id) async {

print('Hive: Querying $table with id $id');
return null;

}

@override
Future delete(String table, String id) async {

print('Hive: Deleting from $table with id $id');

}
}

// High-level module: depends on the abstraction, not the concrete class
class UserService {
final Database _database; // abstraction

UserService(this._database); // injected from outside

Future createUser(User user) async {

await _database.insert('users', user.toJson());

}

Future<User?> getUser(String id) async {

final data = await _database.query('users', id);
return data != null ? User.fromJson(data) : null;

}
}

// Usage — swap implementations without touching UserService
final service = UserService(MySqlDatabase());
final serviceWithHive = UserService(HiveDatabase());

// In tests — inject a mock
final testService = UserService(MockDatabase());

Benefits:

High-level business logic is independent of infrastructure details

Swap implementations (databases, APIs, storage) without changing business code

Test high-level modules with mock implementations

Looser coupling between layers of the system

When to Apply:

When a class directly instantiates its dependencies with new or a constructor call

When changing a low-level module forces changes in high-level modules

When you cannot test a class without its real dependencies being present

GRASP Principles: General Responsibility Assignment Patterns

GRASP stands for General Responsibility Assignment Software Patterns. Introduced by Craig Larman in his book "Applying UML and Patterns," GRASP provides nine patterns that guide how to assign responsibilities to classes and objects.

If SOLID tells you the rules of good object design, GRASP tells you the thinking process behind assigning the right job to the right object.

  1. Information Expert

Definition: Assign a responsibility to the class that has the information needed to fulfill it.

The class that knows the most about the data required to perform a task should be the one to perform that task.

// ❌ BAD: Order total calculated outside the Order class
class OrderService {
double calculateTotal(Order order) {

double total = 0;
for (final item in order.items) {
  total += item.price * item.quantity;
}
return total;

}
}

// ✅ GOOD: Order calculates its own total because it has all the information
class Order {
final List items;
final String id;
final String customerId;

Order({required this.items, required this.id, required this.customerId});

// Order is the Information Expert — it has items, so it calculates total
double get total {

return items.fold(0, (sum, item) => sum + (item.price * item.quantity));

}

bool get isEmpty => items.isEmpty;

int get itemCount => items.length;
}

class OrderItem {
final String productId;
final double price;
final int quantity;

OrderItem({

required this.productId,
required this.price,
required this.quantity,

});
}

The Order class is the Information Expert for its total because it owns the items. Putting calculateTotal in a service class means that service must reach into Order's data to do work that Order is better positioned to do itself.

  1. Creator

Definition: Assign class B the responsibility of creating an instance of class A if B contains A, aggregates A, closely uses A, or has the initializing data for A.

// ✅ GOOD: Order creates OrderItems because Order aggregates them
class Order {
final List _items = [];
final String id;
final String customerId;

Order({required this.id, required this.customerId});

// Order is the Creator of OrderItems — it aggregates them
void addItem(String productId, double price, int quantity) {

_items.add(OrderItem(
  productId: productId,
  price: price,
  quantity: quantity,
));

}

List get items => List.unmodifiable(_items);
}

// ✅ GOOD: OrderFactory creates Orders because it has all the initialization data
class OrderFactory {
final String _customerId;

OrderFactory(this._customerId);

Order createOrder() {

return Order(
  id: DateTime.now().millisecondsSinceEpoch.toString(),
  customerId: _customerId,
);

}
}

  1. Controller

Definition: Assign the responsibility of handling system events to a class that represents the overall system, a use case scenario, or a session.

The Controller is not a UI widget. It is the first object beyond the UI layer that handles a system operation. In Flutter with Clean Architecture, this maps directly to your use case or notifier layer.

// ❌ BAD: UI widget handling business logic directly
class CheckoutWidget extends StatelessWidget {
Future _onCheckout() async {

// Widget is directly handling domain logic
final cart = CartRepository().getCart();
if (cart.isEmpty) return;

final order = Order(id: 'ord_1', customerId: 'usr_1');
await PaymentService().process(order);
await InventoryService().updateStock(order);
await NotificationService().sendConfirmation(order);

}
}

// ✅ GOOD: Controller (use case) handles the system operation
class CheckoutUseCase {
final CartRepository _cartRepository;
final PaymentService _paymentService;
final InventoryService _inventoryService;
final NotificationService _notificationService;

CheckoutUseCase({

required CartRepository cartRepository,
required PaymentService paymentService,
required InventoryService inventoryService,
required NotificationService notificationService,

}) : _cartRepository = cartRepository,

    _paymentService = paymentService,
    _inventoryService = inventoryService,
    _notificationService = notificationService;

Future<Result<Order, AppException>> execute(String customerId) async {

final cart = await _cartRepository.getCart(customerId);
if (cart.isEmpty) return Result.failure(AppException.emptyCart());

final order = Order.fromCart(cart, customerId);
await _paymentService.process(order);
await _inventoryService.updateStock(order);
await _notificationService.sendConfirmation(order);

return Result.success(order);

}
}

// Widget delegates to the controller
class CheckoutWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {

return ElevatedButton(
  onPressed: () => ref.read(checkoutNotifierProvider.notifier).checkout(),
  child: const Text('Checkout'),
);

}
}

  1. Low Coupling

Definition: Assign responsibilities so that coupling (dependency between classes) remains low. A class with low coupling is easier to change, test, and reuse.

Coupling is the degree to which one class knows about or depends on another. High coupling means changing one class forces changes in many others. Low coupling means each class is as independent as possible.

// ❌ BAD: High coupling — UserService knows about specific implementations
class UserService {
final MySqlDatabase _database = MySqlDatabase();
final SmtpEmailService _emailService = SmtpEmailService();
final FirebaseAnalytics _analytics = FirebaseAnalytics();

Future registerUser(User user) async {

await _database.insert('users', user.toJson());
await _emailService.sendWelcome(user.email);
_analytics.logEvent('user_registered');

}
}

// ✅ GOOD: Low coupling — UserService depends on abstractions
class UserService {
final UserRepository _repository;
final EmailService _emailService;
final AnalyticsService _analytics;

UserService({

required UserRepository repository,
required EmailService emailService,
required AnalyticsService analytics,

}) : _repository = repository,

    _emailService = emailService,
    _analytics = analytics;

Future registerUser(User user) async {

await _repository.save(user);
await _emailService.sendWelcome(user.email);
_analytics.logEvent('user_registered');

}
}

Low coupling directly enables testability. When UserService depends on abstractions, you inject mocks in tests and verify behavior without touching a real database, a real email server, or a real analytics service.

  1. High Cohesion

Definition: Assign responsibilities so that cohesion (how closely related a class's responsibilities are) remains high. A class with high cohesion has a clear, focused purpose and all its methods contribute to that purpose.

Low cohesion is the sign of a class that is doing too many unrelated things. High cohesion is the sign of a class that knows exactly what it is and does it well.

// ❌ BAD: Low cohesion — UserManager does unrelated things
class UserManager {
Future saveUser(User user) async { / ... / }
Future sendEmail(String email, String body) async { / ... / }
void generatePdfReport(List users) { / ... / }
Future syncWithExternalApi(User user) async { / ... / }
void logActivity(String message) { / ... / }
}

// ✅ GOOD: High cohesion — each class has a clear, focused purpose
class UserRepository {
Future save(User user) async { / ... / }
Future<User?> findById(String id) async { / ... / }
Future<List> findAll() async { / ... / }
Future delete(String id) async { / ... / }
}

class UserEmailService {
Future sendWelcome(String email) async { / ... / }
Future sendPasswordReset(String email) async { / ... / }
Future sendAccountSuspended(String email) async { / ... / }
}

class UserReportService {
void generatePdfReport(List users) { / ... / }
void generateCsvExport(List users) { / ... / }
}

High cohesion and low coupling are two sides of the same coin. A class with high cohesion naturally tends toward low coupling because it is focused enough to not need many external dependencies.

  1. Polymorphism

Definition: When behavior varies by type, use polymorphism (abstract classes, interfaces, method overriding) rather than conditionals.

Instead of asking "what type is this?" and branching with if-else or switch, define a common interface and let each type handle its own behavior.

// ❌ BAD: Type-checking with conditionals
class NotificationSender {
Future send(String type, String message, String recipient) async {

if (type == 'email') {
  await emailService.send(recipient, message);
} else if (type == 'sms') {
  await smsService.send(recipient, message);
} else if (type == 'push') {
  await pushService.send(recipient, message);
} else if (type == 'whatsapp') {
  await whatsappService.send(recipient, message);
}
// Adding Telegram? Modify this class again.

}
}

// ✅ GOOD: Polymorphism — each notification type handles itself
abstract class NotificationChannel {
Future send(String message, String recipient);
String get channelName;
}

class EmailNotification extends NotificationChannel {
@override
String get channelName => 'Email';

@override
Future send(String message, String recipient) async {

await emailService.send(recipient, message);

}
}

class SmsNotification extends NotificationChannel {
@override
String get channelName => 'SMS';

@override
Future send(String message, String recipient) async {

await smsService.send(recipient, message);

}
}

class PushNotification extends NotificationChannel {
@override
String get channelName => 'Push';

@override
Future send(String message, String recipient) async {

await pushService.send(recipient, message);

}
}

// Adding Telegram requires only a new class, nothing else changes
class TelegramNotification extends NotificationChannel {
@override
String get channelName => 'Telegram';

@override
Future send(String message, String recipient) async {

await telegramService.send(recipient, message);

}
}

class NotificationSender {
Future send(NotificationChannel channel, String message, String recipient) async {

await channel.send(message, recipient);

}
}

  1. Pure Fabrication

Definition: When no natural domain class is a good fit for a responsibility, create an artificial class (a fabrication) to handle it.

Not everything in your system maps neatly to a real-world concept. Sometimes you need a class that exists purely for technical reasons: to keep other classes clean, to handle infrastructure concerns, or to encapsulate a behavior that does not belong anywhere else.

// The domain has User, Order, Product — real world concepts.
// But where does "send an HTTP request" belong? Nowhere in the domain.
// Create a fabrication.

// Pure Fabrication: HttpClient does not represent a real-world concept
// It exists purely to encapsulate HTTP communication concerns
class ApiHttpClient {
final String _baseUrl;
final Map<String, String> _defaultHeaders;

ApiHttpClient({

required String baseUrl,
required String authToken,

}) : _baseUrl = baseUrl,

    _defaultHeaders = {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $authToken',
    };

Future<Map<String, dynamic>> get(String path) async {

final response = await http.get(
  Uri.parse('$_baseUrl$path'),
  headers: _defaultHeaders,
);
return _handleResponse(response);

}

Future<Map<String, dynamic>> post(

String path,
Map<String, dynamic> body,

) async {

final response = await http.post(
  Uri.parse('$_baseUrl$path'),
  headers: _defaultHeaders,
  body: jsonEncode(body),
);
return _handleResponse(response);

}

Map<String, dynamic> _handleResponse(http.Response response) {

if (response.statusCode >= 200 && response.statusCode < 300) {
  return jsonDecode(response.body);
}
throw ApiException(
  statusCode: response.statusCode,
  message: response.body,
);

}
}

ApiHttpClient does not represent a person, a product, or an order. It is a fabrication that exists purely to manage HTTP communication so that data sources and repositories do not have to deal with raw HTTP details.

  1. Indirection

Definition: Assign responsibility to an intermediate object to mediate between components, reducing direct coupling.

Indirection is about introducing a middleman that coordinates between two things that should not know about each other directly.

// ❌ BAD: Presentation directly depends on data layer
class UserProfileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {

// Widget directly calling the database
final db = MySqlDatabase();
final user = db.query('users', 'user_123');
return Text(user['name']);

}
}

// ✅ GOOD: Indirection through repository and use case layers

// The repository is an indirection layer between use cases and the database
abstract class UserRepository {
Future<User?> findById(String id);
}

class UserRepositoryImpl implements UserRepository {
final Database _database;
UserRepositoryImpl(this._database);

@override
Future<User?> findById(String id) async {

final data = await _database.query('users', id);
return data != null ? User.fromJson(data) : null;

}
}

// The use case is an indirection layer between presentation and domain
class GetUserUseCase {
final UserRepository _repository;
GetUserUseCase(this._repository);

Future<Result<User, AppException>> execute(String userId) async {

final user = await _repository.findById(userId);
if (user == null) return Result.failure(AppException.notFound());
return Result.success(user);

}
}

// Widget depends only on the notifier — completely decoupled from data layer
class UserProfileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {

final userState = ref.watch(userProfileProvider);
return userState.when(
  data: (user) => Text(user.name),
  loading: () => const CircularProgressIndicator(),
  error: (e, _) => Text('Error: $e'),
);

}
}

Each layer is an indirection point. The widget does not know about the repository. The repository does not know about the widget. The use case sits in between and coordinates without creating tight coupling between the extremes.

  1. Protected Variations

Definition: Identify points of predicted variation or instability and assign responsibilities to create a stable interface around them.

Wrap the things most likely to change behind a stable abstraction. Code that is unlikely to change depends on the stable interface, not on the unstable detail.

// Payment providers change — Stripe today, Flutterwave tomorrow
// Protect everything that uses payment from this variation

abstract class PaymentGateway {
Future charge(double amount, String currency, String token);
Future refund(String transactionId, double amount);
}

// Stripe implementation — likely to change or be replaced
class StripeGateway implements PaymentGateway {
final String _apiKey;
StripeGateway(this._apiKey);

@override
Future charge(double amount, String currency, String token) async {

// Stripe-specific API calls
print('Stripe: charging $amount $currency');
return PaymentResult(transactionId: 'stripe_txn_001', success: true);

}

@override
Future refund(String transactionId, double amount) async {

print('Stripe: refunding $transactionId for $amount');

}
}

// Flutterwave implementation — could be swapped in with no changes to business logic
class FlutterwaveGateway implements PaymentGateway {
final String _publicKey;
FlutterwaveGateway(this._publicKey);

@override
Future charge(double amount, String currency, String token) async {

print('Flutterwave: charging $amount $currency');
return PaymentResult(transactionId: 'fw_txn_001', success: true);

}

@override
Future refund(String transactionId, double amount) async {

print('Flutterwave: refunding $transactionId for $amount');

}
}

// All business logic uses the stable PaymentGateway abstraction
// It is fully protected from the variation of which gateway is in use
class PaymentUseCase {
final PaymentGateway _gateway;
PaymentUseCase(this._gateway);

Future processPayment(double amount, String token) async {

return await _gateway.charge(amount, 'NGN', token);

}
}

2 Comments

0 votes
0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

🏗️✨ The SOLID Principles: 5 Golden Rules for Super Code! 💎🧒

Mahmoud Essam - Jun 9

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Most Startups Add AI Too Early — Here’s How I Decide When It’s Worth It

kajolshah - Jan 8

Chrome Cut Android Scroll Jank 48%: What to Check on Your Site

ApogeeWatcherverified - Jul 29
chevron_left
1Posts
0Comments
1Connections
Mobile Engineering Lead and Senior Software Engineer with 7+ years of experience building and leadin... Show more

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!