If you've done any software development, you've probably experienced that moment where changing one part breaks everything else.
This is the classic problem of high coupling. It makes maintenance harder and increases the risk of every feature change.
So how can we reduce coupling and apply a more structural design?
What Is Coupling?#
Coupling refers to the degree of interdependence between modules or classes.
- High coupling: Heavy interdependency; changing one part causes cascading effects on others
- Low coupling: Roles and responsibilities are well-separated, minimizing the impact of changes
Ultimately, the direction we should aim for is writing code with "high cohesion, low coupling."
Cohesion refers to the degree to which the elements inside a class or module focus on a single purpose.
Problems Caused by Coupling
- Increased maintenance difficulty
- Expanded scope of changes
- Increased instability in testing and deployment
This is why we introduce architecture to solve these problems.
Architecture isn't just about making code structure look pretty -- it's a contract for building systems that are resilient to change.
How to Identify Coupling#
- Dependency graph analysis: Visualize connectivity between classes or modules
- Code review & static analysis tools
- Law of Demeter check: Maintain encapsulation without exposing internal object structure externally
SOLID Principles and DI#
At the foundation of architecture lie the SOLID principles.
Among them, SRP (Single Responsibility Principle), IoC (Inversion of Control), and DI (Dependency Injection) are the core elements for reducing coupling.
There are three approaches to DI:
- Constructor injection (DI via Constructor) -- The safest and most preferred approach
- Setter method injection -- Has potential for runtime errors
- Field injection (Class field) -- Difficult for testing and maintenance
Constructor DI Example
class SomeService {
final SomeDependency dependency;
SomeService(this.dependency);
}Setter DI
class SomeService {
SomeDependency? dependency;
set setDependency(SomeDependency value) => dependency = value;
}ServiceLocator (get_it package example)
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setup() {
getIt.registerSingleton<SomeService>(SomeService(SomeDependency()));
}
// Usage
final service = getIt<SomeService>();Prefer constructor DI first, and use Setter or ServiceLocator as needed.
Just the habit of not creating external objects directly but receiving them from outside significantly reduces coupling.
Separating Roles and Responsibilities#
The core of applying architecture is distinguishing between Role and Responsibility.
- Role: What should be done (abstraction)
- Responsibility: How to do it (implementation)
If you don't separate roles and dump all responsibilities into a single class, the impact of changes grows and reusability drops.
Good Collaboration vs Bad Collaboration#
In code, coupling differences also arise from collaboration patterns.
- Good collaboration: Tell, Unidirectional, Weak
- Don't ask for state -- command it (Tell)
- Maintain unidirectional dependencies
- Weak dependencies that minimize the impact of changes
- Bad collaboration: Ask, Bidirectional, Strong
- Directly accessing state (Ask) -> breaks encapsulation
- Bidirectional dependencies -> leads to spaghetti code
- Strong dependencies -> cascading errors on change
Applying Architecture in Flutter#
In Flutter development, you can separate concerns by dividing the main areas into Presentation, Business Logic, and Data Layer.
- Presentation: Screen (UI) and user input handling
- Business Logic: Computation and domain rule application
- Data Layer: Data storage and retrieval via APIs, DB, caching, etc.
For example, if you put both input handling and data loading in a Controller class, it becomes a massive class with heavy coupling.
Splitting it up and separating by layer responsibility is the core of Feature-First architecture.
Feature-First Architecture Example#
Apply layer separation for each feature like this:
lib/
├── features/
│ └── product/
│ ├── domain/
│ │ ├── entities/
│ │ └── usecases/
│ ├── data/
│ │ ├── models/
│ │ └── repositories/
│ ├── presentation/
│ │ ├── screens/
│ │ └── widgets/With this separation, independent development and testing per feature becomes possible.
Data Passing Patterns#
When passing data between pages in Flutter, there are two approaches:
- Passing objects via extra -- Pass data directly during screen transitions
- Pros: Immediately usable
- Cons: Need exception handling when data is missing or empty
- Pass an identifier, then fetch data -- The next page fetches data by ID
- Pros: Clear flow and data integrity
- Cons: Requires additional API calls
Architecture Evaluation: C4 Model#
The C4 model divides a system into four levels for visual analysis.
- Context -- Interaction between the system and its external environment
- Container -- Separation into executable units
- Component -- Internal building blocks within containers
- Code -- Actual class and function level
Using this, you can visually analyze dependencies and responsibility boundaries while quantitatively evaluating architecture quality.
Escaping the Anemic Domain Model#
Objects without business logic are anemic domain models. To prevent this:
- Domain layer: Define concepts and rules with Entity, Value Object, Domain Event, etc.
- Application layer: Execute use cases, connect domain with external systems
- Persistence layer: DB storage and retrieval
- Infrastructure layer: External SDKs, messaging, error reporting
By combining horizontal (by role) and vertical (by feature) separation, you can maintain a clean architecture.
Wisdom is knowing what to do next; Skill is knowing how ot do it, and Virtue is doing it.
-- David Jordan