This article shares an approach to the development workflow for frontend applications within the Clean Architecture.
Repository with example:
https://github.com/harunou/frontend-clean-architecture-exercise
Building a frontend application that follows Clean Architecture is simple and straightforward. The general approach is to build units according to the Clean Architecture UML implementation, connecting them step by step to get the application up and running.
Let's examine the development workflow of a counter application in detail.
Before starting the implementation process, a developer typically has:
- The application requirements:
- The application should display a counter value.
- The application should display the counter status based on its value:
Positive
,Negative
, orZero
. - The application should allow incrementing the counter value.
- The application should allow decrementing the counter value.
- The application should allow resetting the counter value to
0
. - The counter logic is performed on the server side.
- The initial counter value is stored on the server side.
- The initial counter value before the first request is
0
.
- A basic sketch of the user interface:
To build the application, we will use the Clean Architecture UML diagram for frontend applications.
Step 1: Add view, controller, and presenter units
The first step is to create the view unit, which is based on the user interface sketch. The view is the most straightforward unit to implement at this stage, as it focuses on presenting data and handling user interactions. If the backend is ready and API definitions are available, the external resource unit can be developed in parallel with the view. In this example, we assume the backend is not yet ready, so we start with the view.
Building the view unit helps to understand the application, while getting something (the view unit) done. For a simple counter application, we can use mocked presenter and controller units to provide test data and handlers, making it possible to test the view in the browser right away. These presenter and controller units can be easily extracted later as the application evolves.
function Counter() {
// presenter unit implemented with constants (null presenter)
// the presenter has assigned mocked data for testing in browser
const countValue = 0;
const countStatus = "Positive";
// controller unit implemented with constants (null controller)
// the controller has assigned mocked data for testing in browser
const onIncrementButtonClick = () => {};
const onDecrementButtonClick = () => {};
const onResetButtonClick = () => {};
// view unit
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg p-10 text-center max-w-sm w-full">
{/* Header */}
<h1 className="text-3xl font-bold text-gray-800 mb-8">Counter App</h1>
{/* Counter Value Display */}
<div className="bg-gray-50 rounded-xl py-8 mb-8 border-2 border-gray-200">
<span className="text-6xl font-bold text-indigo-600">
{countValue}
</span>
</div>
{/* Control Buttons */}
<div className="flex gap-4 justify-center mb-6">
<button
onClick={onDecrementButtonClick}
className="w-16 h-16 bg-red-500 hover:bg-red-600 text-white text-2xl font-bold rounded-xl shadow-md hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200"
>
-
</button>
<button
onClick={onIncrementButtonClick}
className="w-16 h-16 bg-green-500 hover:bg-green-600 text-white text-2xl font-bold rounded-xl shadow-md hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200"
>
+
</button>
</div>
{/* Reset Button */}
<button
onClick={onResetButtonClick}
className="w-full py-3 px-6 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-xl shadow-md hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200"
>
Reset to Zero
</button>
{/* Count Status Display */}
<p className="text-gray-500 text-sm mt-6">
Status: <span className={`font-semibold`}>{countStatus}</span>
</p>
</div>
</div>
);
}
At this stage, the view unit is considered closed.
Step 2: Add entities store unit
Once the view unit is ready, we can analyze the controller and presenter interfaces and design the entities store unit. The entities store unit is responsible for storing the application's entities, which are minimal data structures that the application works with. In our case, the entities store unit will contain a single entity: the counter, with a count
value and a setCount
setter function.
const INITIAL_COUNT = 0;
function Counter() {
// entities store unit (counter entity)
const [count, setCount] = useState(INITIAL_COUNT);
// presenter unit implemented with constants (null presenter)
// the presenter has assigned mocked data for testing in browser
const countValue = 0;
const countStatus = "Positive";
// controller unit implemented with constants (null controller)
// the controller has assigned mocked data for testing in browser
const onIncrementButtonClick = () => {};
const onDecrementButtonClick = () => {};
const onResetButtonClick = () => {};
// view unit
return (
...
);
}
At this stage, the entities store unit is considered closed.
Step 3: Add gateway unit factory
Since the application connects to a backend service, the next step is to introduce the gateway unit factory. The gateway unit serves as an abstraction layer between the application and external resources.
In our counter application, the factory will provide a gateway unit with methods to access server-side logic. A simple gateway unit factory might look like this:
// gateway unit interface (yet empty)
interface CounterGateway {}
// gateway unit factory
const useCounterGateway = (): CounterGateway => {
return {} as CounterGateway;
};
const INITIAL_COUNT = 0;
function Counter() {
// entities store unit (counter entity)
const [count, setCount] = useState(INITIAL_COUNT);
// gateway unit
const gateway = useCounterGateway();
// presenter unit implemented with constants (null presenter)
// the presenter has assigned mocked data for testing in browser
const countValue = 0;
const countStatus = "Positive";
// controller unit implemented with constants (null controller)
// the controller has assigned mocked data for testing in browser
const onIncrementButtonClick = () => {};
const onDecrementButtonClick = () => {};
const onResetButtonClick = () => {};
// view unit
return (
...
);
}
By introducing the gateway unit factory at this stage, you ensure that your application remains decoupled from the specifics of the backend implementation.
Step 4: Connect the presenter unit to the entities store unit
In this step, we connect the presenter unit property by property to the entities store unit. The presenter unit is responsible for transforming the raw data from the entities store into a format suitable for the view. This separation ensures that the view remains simple and focused only on presentation logic, while the presenter handles any necessary data formatting or status calculation.
For the counter application, the presenter unit will derive the countValue
and countStatus
from the counter
entity. For example, the status can be determined based on whether the count is positive, negative, or zero.
Here is how you might connect the entities store to the presenter unit:
// gateway unit interface (yet empty)
interface CounterGateway {}
// gateway unit factory
const useCounterGateway = (): CounterGateway => {
return {} as CounterGateway;
};
const INITIAL_COUNT = 0;
function Counter() {
// entities store unit (counter entity)
const [count, setCount] = useState(INITIAL_COUNT);
// gateway unit
const gateway = useCounterGateway();
// presenter unit implemented with constants (null presenter)
const countValue = count;
const countStatus =
count === 0 ? "Zero" : count > 0 ? "Positive" : "Negative";
// controller unit implemented with constants (null controller)
// the controller has assigned mocked data for testing in browser
const onIncrementButtonClick = () => {};
const onDecrementButtonClick = () => {};
const onResetButtonClick = () => {};
// view unit
return (
...
);
}
By connecting the entities store to the presenter unit, you ensure that any changes in the entities store unit are immediately reflected in the view.
At this stage, the presenter unit is considered closed.
Step 5: Connect the controller unit to the entities store unit and the gateway unit
In this step, we connect the controller unit to both the entities store unit and the gateway unit by implementing inlined use case interactors. The controller unit handles user interactions, updates the application's state, and communicates with the backend through the gateway. This process also helps to define the CounterGateway
interface.
The example below demonstrates how the controller unit performs operations using pessimistic updates:
// gateway unit interface
interface CounterGateway {
getCount: () => Promise<number>;
incrementCount: () => Promise<number>;
decrementCount: () => Promise<number>;
resetCount: () => Promise<number>;
}
// gateway unit factory
const useCounterGateway = (): CounterGateway => {
return {} as CounterGateway;
};
const INITIAL_COUNT = 0;
function Counter() {
// entities store unit (counter entity)
const [count, setCount] = useState(INITIAL_COUNT);
// gateway unit
const gateway = useCounterGateway();
// presenter unit implemented with constants (null presenter)
const countValue = count;
const countStatus =
count === 0 ? "Zero" : count > 0 ? "Positive" : "Negative";
// controller unit implemented with constants (null controller)
const onIncrementButtonClick = async () => {
const remoteCount = await gateway.incrementCount();
setCount(remoteCount);
};
const onDecrementButtonClick = async () => {
const remoteCount = await gateway.decrementCount();
setCount(remoteCount);
};
const onResetButtonClick = async () => {
const remoteCount = await gateway.resetCount();
setCount(remoteCount);
};
const onCounterMount = async () => {
const remoteCount = await gateway.getCount();
setCount(remoteCount);
};
// view unit lifecycle hook
useEffect(() => {
onCounterMount();
}, []);
// view unit
return (
...
);
}
At this stage, the controller unit is considered closed.
Step 6: Add the in-memory gateway unit
Until the backend is ready, you can implement an in-memory gateway unit to simulate server-side logic. This allows you to develop and test the application independently of the backend, ensuring the rest of your architecture works as expected.
The in-memory gateway unit should implement the CounterGateway
interface and manage the counter value in local memory. Each method will update or return the value as if it were communicating with a real server.
Here is an example of an in-memory gateway unit implementation:
// FILE: types.ts
export interface CounterGateway {
getCount: () => Promise<number>;
incrementCount: () => Promise<number>;
decrementCount: () => Promise<number>;
resetCount: () => Promise<number>;
}
// FILE: gateways.ts
let count = 5;
// counter gateway unit (in-memory)
export const inMemoryCounterGateway: CounterGateway = {
getCount: async () => {
return count;
},
incrementCount: async () => {
count += 1;
return count;
},
decrementCount: async () => {
count -= 1;
return count;
},
resetCount: async () => {
count = 0;
return count;
},
};
// FILE: hooks.ts
// gateway unit factory
export const useCounterGateway = (): CounterGateway => {
return inMemoryCounterGateway;
};
Implementing the in-memory gateway unit and updating the useCounterGateway
hook did not require any changes to the Counter
component. This demonstrates how Clean Architecture allows you to swap gateway implementations without affecting the application core.
At this stage, the in-memory gateway unit is considered closed.
Step 7: Implement the external resource unit
Once the backend service is ready, we implement the external resource unit, which represents the counter backend service in our application. The external resource unit encapsulates all HTTP requests related to the service, providing a clear interface for fetching and updating the counter value on the server.
The counter external resource unit could look like this:
// FILE: api.ts
interface ApiCountDto {
value: number;
}
// counter external resource unit
export const CounterApi = {
async getCount(): Promise<ApiCountDto> {
const response = await fetch("/api/counter");
if (!response.ok) {
throw new Error("Failed to fetch count");
}
return response.json();
},
async incrementCount(): Promise<ApiCountDto> {
const response = await fetch("/api/counter/increment", { method: "POST" });
if (!response.ok) {
throw new Error("Failed to increment count");
}
return response.json();
},
async decrementCount(): Promise<ApiCountDto> {
const response = await fetch("/api/counter/decrement", { method: "POST" });
if (!response.ok) {
throw new Error("Failed to decrement count");
}
return response.json();
},
async resetCount(): Promise<ApiCountDto> {
const response = await fetch("/api/counter/reset", { method: "POST" });
if (!response.ok) {
throw new Error("Failed to reset count");
}
return response.json();
},
};
At this stage, the external resource unit is considered closed.
Step 8: Add the remote gateway unit
In this step, we add the remote gateway unit, which acts as an adapter between the application's core logic and the external resource unit (API). The remote gateway implements the CounterGateway
interface and delegates all operations to the CounterApi
methods, converting the API responses into the format expected by the rest of the application.
The code also shows how the useCounterGateway
hook can dynamically select between the in-memory gateway (for development) and the remote gateway (for production).
// FILE: gateways.ts
// counter gateway unit (remote)
export const remoteCounterGateway: CounterGateway = {
getCount: async () => {
const response = await CounterApi.getCount();
return response.value;
},
incrementCount: async () => {
const response = await CounterApi.incrementCount();
return response.value;
},
decrementCount: async () => {
const response = await CounterApi.decrementCount();
return response.value;
},
resetCount: async () => {
const response = await CounterApi.resetCount();
return response.value;
},
};
// FILE: hooks.ts
// gateway unit factory
export const useCounterGateway = (): CounterGateway => {
if (import.meta.env.DEV) {
return inMemoryCounterGateway;
}
return remoteCounterGateway;
};
Implementing the remote gateway unit and updating the useCounterGateway
hook did not require any changes to the Counter
component. This demonstrates how Clean Architecture allows you to swap gateway implementations without affecting the application core.
At this stage, the remote gateway unit is considered closed and the counter app is ready for production.
Conclusion
This workflow shows how Clean Architecture enables you to incrementally build frontend applications or features. It improves code quality, streamlines collaboration, supports parallel work, and simplifies long-term maintenance.
Top comments (0)