The Game Plan: Scaling React Applications with Clean Architecture
.jpg)
Introduction
In sports, mastering the fundamentals and maintaining discipline are key to success. The same principles apply when building complex systems for real money games. Clean Architecture, much like a solid work ethic, ensures focus on core principles even as systems evolve across platforms. Just as a skilled player adapts to different teams, Clean Architecture enables us to scale journeys like AddCash, Withdraw, and KYC across multiple games and UIs while maintaining core functionality. In this blog, I’ll share how Clean Architecture gave us the flexibility to build scalable, maintainable systems that thrive across platforms.
Case Study
At Games24x7, we manage several games across desktop, mobile web, Android and iOS. While core functionalities like AddCash, MyAccount, Withdraw, and KYCare consistent, the UIs differ based on each game’s theme. Maintaining separate React apps for every game’s journey due to these UI differences became unmanageable over time. Initially, we used WebView to embed these journeys, but this approach became inefficient as the number of variants grew.
This challenge led us to adopt Clean Architecture to create scalable and maintainable systems.
For Example: All the screenshots below are our add cash journey across different platforms and products, powered with a common logical layers but independent UI layers.



Problem:
The screenshots above clearly illustrate that the underlying business problem remains the same across all three journeys, despite the varying UI layouts. To address these types of problems, it's common to start by creating a React app where different Boolean flags or UI layout configurations are used to control conditional rendering. While this approach may seem straight forward initially, it often leads to an overcomplicated codebase with numerous if-else statements, excessive state manipulation, and an ever-growing list of common functions. With each new iteration of the product spec, the code becomes increasingly difficult to maintain, debug, and extend. This leads to a tangled architecture that slows down development and increases the likelihood of introducing bugs as new features are added.
To implement clean code, we made several adjustments to our approach in developing the Micro Frontend App. Our agenda was clear: we aimed to write code that accurately reflects our business needs while ensuring that our presentation layer remains as streamlined as possible. This involved defining business entities, establishing core business rules, and creating enums, interfaces, and adapters that are separate from the UI layer. Below We will explore some essential adjustments and standardizations that we implemented, which contributed to our success in writing Clean Code.
Folder Structure
Over the years, we have encountered a significant challenge: our frontend applications began to resemble the frameworks used for their development. It became quite apparent, simply by examining the repository, whether the application was built using React, Angular, or as a native app. We aimed to design our folder structure to mirror the user stories system for which it was being developed. The choice of framework for our presentation layer is flexible and does not significantly impact this structure. Below is an example of our Addcash MicroFrontEnd
Folder Structure is divided into mainly two folders
- Common Folder: This folder serves as the central repository for all our user stories, business logic, business rules, and interface adapters. The business logic is organized within the use cases folder, while the entities encompass all the data models.
- Views Folder: The view layer comprises the code for various user interface(UI) variants, focusing exclusively on the presentation layer. Each folder represents a distinct variant of a specific micro front end and may utilize different frameworks.
Note: The nomenclature of various folders and the types of code contained within each will be thoroughly examined in an upcoming MicroFrontEnd blog. A link to this blog will be provided once it is published.
Entities:
In Clean Code, an Entity is an object defined by its unique identity rather than its attributes. It has a lifecycle where its state can change, but its identity remains constant. Entities are typically mutable and persist over time, allowing them to be tracked and differentiated across operations, even as their attributes evolve.
In the above screenshot one of the entity we can figure out is of tile. In all the variants there are array of tile. Each tile will have an Identity. It will have a Lifecycle, Mutable Attributes and some DomainLogic. Below is an example of Tile entity.
class Tile {
tileId: string; // Unique Identifier (Identity)
amount: number;
bonus: number;
nameTag: string;
defaultTile: boolean;
/**
* Creates an instance of Tile.
* @param {string} tileId - Unique identifier for the Tile.
* @memberof Tile
*/
constructor(tileId: string) {
this.tileId = tileId; // Identity of the Tile (Unique ID)
this.nameTag = '';
this.amount = 0;
this.bonus = 0;
this.defaultTile = false;
}
/**
* Initialize the Tile with data.
* @param {TileData} tileData
* @memberof Tile
*/
initialise(tileData: any) {
this.amount = tileData.tileAmount;
this.bonus = tileData.tileBonusAmount || 0;
this.nameTag = tileData.tileTag;
this.instantCash = tileData.tileInstantCash || 0;
this.defaultTile = tileData.isDefaultTile;
}
}
// Getters for various tile attributes
getAmount() {
return this.amount;
}
getOfferOnTile() {
return this.offerOnTile;
}
getBonus() {
return this.bonus;
}
getHasBonusCash() {
return this.hasBonusCash;
}
calculateBonusFromBucket = (
bucket: Bucket,
enteredAmount: number,
bonusType: number,
) => {
let bonusAmount = 0;
if (bonusType === 1 && bucket.bonusAmount) {
bonusAmount = bucket.bonusAmount;
} else if (bonusType === 2 && bucket.bonusPercentage) {
bonusAmount = Math.floor((enteredAmount * bucket.bonusPercentage) / 100);
if (bucket.maxBonusAmount && bonusAmount > bucket.maxBonusAmount) {
bonusAmount = bucket.maxBonusAmount;
}
}
return bonusAmount;
};
}
export default Tile;
Breakdown of Changes toReflect Entity Concepts:
- Identity (
tileId
): I’ve added atileId
property to uniquely identify eachTile
. This makes the object an Entity because it now has an identity that distinguishes it from other tiles. - Lifecycle: The tile can be initialized with various properties (
amount
,bonus
, etc.) and can be updated or mutated through theinitialise
method. However, thetileId
stays constant throughout its lifecycle. - Mutable Attributes: The attributes of the tile (
amount
,bonus
,nameTag
, etc.) can change over time, which is typical for entities in DDD. - Domain Logic: calculateBonusFromBucket is a piece of domain logic that manages the bonus amount for a tile based on its selection. It tracks and calculates the bonus dynamically, depending on whether the tile has been selected or not. This ensures that the bonus value is correctly adjusted according to the business rules, allowing the tile's behavior to reflect the context of its selection.
Use Cases in Action: processPayment
In Clean Architecture, use cases are typically represented as application services that orchestrate workflows and encapsulate business logic. While use cases don’t contain complex business logic themselves, they delegate that responsibility to the domain model. Their primary role is to manage the flow of events and ensure the system responds to user interactions, making the muser-centric and aligned with real-world business processes.
Example: The processPayment
Use Case
async function processPayment(orderId: string, paymentDetails: PaymentDetails): Promise<Order> {
// Step 1: Retrieve the order to be processed
const order = await orderRepository.findById(orderId);
if (!order) throw new Error("Order not found");
// Step 2: Process payment using an external payment gateway
const paymentResult = await paymentGateway.processPayment(paymentDetails);
if (!paymentResult.success) {
// Step 3: Handle payment failure
order.status = 'PAYMENT_FAILED';
await orderRepository.save(order);
await notificationService.notifyPaymentFailure(order);
throw new Error("Payment failed");
}
// Step 4: Handle successful payment
order.status = 'PAID';
order.paymentDate = new Date();
await orderRepository.save(order);
await notificationService.notifyPaymentSuccess(order);
// Step 5: Return the updated order
return order;
}
The Payment process use case involves multiple steps for validating and retrieving orders. It interacts with various services via APIs and communicates with models to update the order status. While it does not contain domain logic, it plays a crucial role in mutating state and helps trigger different lifecycles of entities.
View Layer:
The view layer serves primarily as a presentation layer. At Games24x7, we typically develop our journeys using React and React Native. Redux, a predictable state management library for JavaScript applications, is often utilized alongside React. In this context, reducers in Redux contain arrays of tile entities and other entities essential for our business logic. The entire presentation layer is rendered through these Redux states.
Additionally, the orchestration of communication between various entities and the backend is managed through use cases. This approach enables us to maintain strict standards, ensuring that our view layer remains uncluttered by unnecessary logic, which in turn helps keep our code predictable and clean.
Advantages:
- Testability: Test-Driven Development (TDD) can indeed be effectively implemented. It allows for the testing of all business user stories as a complete system, down to individual units.
- Predictability: Architecture principles play a crucial role in guiding us on where each piece of code should be implemented and how different units will interact with one another. They ensure that developers, from junior to senior levels, can consistently write code. This predictability significantly simplifies maintenance and debugging processes. Ultimately, this is the primary reason we utilize frameworks, isn't it?
- Independent of Frameworks: Core logic operates independently of external frameworks or libraries, providing significant flexibility to transition between different frameworks. This adaptability aids in refactoring and maintenance. Moreover, it ensures a clear separation of concerns, allowing your logic to reside precisely where the data is located.
- Supports Domain-Driven Design (DDD): Aligns with DDD principles for a rich, well-defined domain model.
- Improved Communication: Enhances collaboration between teams with clear responsibilities.
About the Author:
AmitKumar Singh is a distinguished Frontend Technology Architect at Games24x7 with a flair for solving intricate problems and designing streamlined, efficient solutions. With a deep expertise in the latest frontend technologies, they specialize inbuilding innovative and user-friendly web applications. Known for their ability to simplify complex requirements, Amit consistently delivers high-performance digital experiences.
Explore More
Discover the latest insights from the world of Games24x7