Architecting Robust Software: Embracing Modular Design

How modular design, particularly through use cases, repositories, and a clear testing strategy, can lead to a robust, maintainable, and scalable software application.

The Story Begins:

In the ever-evolving landscape of software development, structuring a project efficiently is as crucial as writing the code itself. In this post, we’ll dive deep into the architecture of a hypothetical project, “Project-X”, and explore how modular design, particularly through use cases, repositories, and a clear testing strategy, can lead to a robust, maintainable, and scalable software application.

The Project-X Architecture

Project-X is conceptualized as a service-oriented application with a keen focus on modular and clean code. The architecture is broken down into several key components:

  1. Services Layer: This is the heart of our application, where each service (like user) represents a domain of business logic.
  2. Core Directory: Within each service, the core directory holds the primary logic, divided further into models, repositories, use cases, and integrations.
  3. Models: Here lies our User class, a blueprint for user objects, enriched with methods pertinent to user functionalities.
  4. Repositories: The userDb.ts represents our bridge to the database, encapsulating all the data access logic.
  5. Integrations: Renamed from ‘services’ for clarity, this section handles external integrations like ElasticSearch or S3 buckets.
  6. Use Cases: This is where the magic happens. Each use case (like checkIfUserExists or updateUserDetails) contains specific business operations. They are designed to be pure as much as possible, promoting reusability and testability.
  7. Handlers: In an AWS Lambda context, handlers like updateUserDetailsHandler serve as entry points, invoking the respective use cases.

Visual Representation of the above descriptions

Flow of Execution: Updating User Email in Project-X

1. The Handler: updateUserDetailsHandler.ts

The journey begins with the handler, which serves as the entry point responding to an API request.

// handlers/updateUserDetailsHandler.ts
import { APIGatewayProxyHandler } from ''aws-lambda'';
import * as Joi from ''joi'';
import { updateUserDetails } from ''../useCases/updateUserDetails'';

const updateUserDetailsSchema = Joi.object({
    username: Joi.string().required(),
    newEmail: Joi.string().email().required()
});

export const updateUserDetailsHandler: APIGatewayProxyHandler = async (event) => {
    try {
        // Parse the request body and validate it against the schema
        const { username, newEmail } = await updateUserDetailsSchema.validateAsync(JSON.parse(event.body));

        await updateUserDetails(username, newEmail);
        return { statusCode: 200, body: JSON.stringify({ message: ''Email updated successfully'' }) };
    } catch (error) {
        // If there''s a validation error, return a 400 status code
        if (error.isJoi) {
            return { statusCode: 400, body: JSON.stringify({ error: error.details[0].message }) };
        }
        // For other errors, return a 500 status code
        return { statusCode: 500, body: JSON.stringify({ error: error.message }) };
    }
};

2. The Use Case: updateUserDetails.ts

The use case orchestrates the business logic, handling the email update process.

// useCases/updateUserDetails.ts
import { userDatabase } from ''../repositories/userDb'';
import { User } from ''../models/User'';

export const updateUserDetails = async (username: string, newEmail: string) => {
    const existingUser = await userDatabase.getUserByUsername(username);
    if (!existingUser) {
        throw new Error(''User not found'');
    }
    const user = new User(existingUser.username, existingUser.email, existingUser.password);
    user.updateEmail(newEmail);

    await userDatabase.updateUserDetails(user);
};

3. The Model: User.ts

The User class encapsulates user data and behaviors, including the ability to update the email.typescriptCopy code

// models/User.ts
export class User {
    username: string;
    email: string;
    password: string;

    constructor(username: string, email: string, password: string) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

    updateEmail(newEmail: string) {
        this.email = newEmail;
    }
}

4. The Repository: userDb.ts

The repository interacts with the database, executing operations like updating user details.

// repositories/userDb.ts
import { Pool } from ''pg'';
import { User } from ''../models/User'';
const pool = new Pool({ /* ... database config ... */ });
export const updateUserDetails = async (user: User) => {
    const queryText = ''UPDATE users SET email = $1 WHERE username = $2'';
    await pool.query(queryText, [user.email, user.username]);
};
export const getUserByUsername = async (username: string) => {
    const queryText = ''SELECT * FROM users WHERE username = $1'';
    const { rows } = await pool.query(queryText, [username]);
    return rows[0];
};

Understanding the Flow

  • Handler (Start Point): The updateUserDetailsHandler kicks off the process by receiving the API request and extracting necessary data.
  • Use Case (Business Logic): It then calls updateUserDetails, passing the username and new email. This function creates a User instance, invokes the method to update the email, and then passes the entire User object to the repository for persistence.
  • Model (Data Encapsulation): The User class encapsulates user-related data and behavior, like updating the email.
  • Repository (Data Persistence): Finally, the userDb repository updates the user''s email in the database.

This flow exemplifies a clean, modular approach to software design, where each component has a specific responsibility, contributing to a cohesive and maintainable codebase.

Embracing Modularity and Testing

Modularity

Modularity in Project-X is not just a design choice; it’s a philosophy. By encapsulating specific functionalities into distinct use cases, we promote single responsibility and separation of concerns. This approach not only makes our code more organized but also enhances maintainability.

Testing Strategy

A robust testing strategy is pivotal in our architecture:

  • Unit Tests: Each use case is backed by unit tests, ensuring that individual pieces of business logic perform as expected.
  • Integration Tests: These tests check the interactions between use cases and repositories, as well as with external services.
  • End-to-End (E2E) Tests: Simulating real-user scenarios, E2E tests validate the application from the front end to the back end, ensuring the entire system functions harmoniously.

Adding a New Perspective: Onboarding and Readability

One often overlooked aspect of software architecture is its impact on onboarding new developers. In “Project-X”, the structure isn’t just about efficient coding; it’s akin to well-organized chapters in a book. Each part of the project — be it services, use cases, or repositories — is like a chapter that tells a part of the story.

Clarity in Structure: A Guide for Newcomers

When a new developer joins the team, the intuitive structure of Project-X acts as a guide. They can easily understand the “flow” of the application, much like reading a well-structured book. Each ‘chapter’ (component) is self-contained with a clear purpose:

  • Handlers: The climax where actions are triggered.
  • Use Cases: Narrate specific scenarios or plot points.
  • Models: Define the main characters (data structures).
  • Repositories: Like a backstory, they handle the historical data interactions.
  • Integrations: This is where the subplots unfold. It’s home to all the external services and third-party integrations — the ElasticSearch, the S3 buckets, and more. Each integration adds a unique flavor, much like a subplot that brings a new twist to the tale.

This logical segregation ensures that even if someone jumps into the middle of the ‘book’, they can easily catch up with the storyline (project logic).

Sequential Flow and Dependency

Just as a well-written story flows logically from one chapter to the next, so does the code in Project-X. Dependencies and interactions between different ‘chapters’ (like use cases using other use cases) are clear and logical. This sequential flow ensures that while the overall story (the application) makes sense, it also maintains its coherence when chapters are read individually.

Wrapping Up: The Big Picture

In essence, the architecture of Project-X isn’t just about the technicalities of coding; it’s about crafting a story that makes sense from any point you start reading. For a new developer, this means a smoother onboarding experience. For the seasoned developer, it’s about maintaining and scaling the application without losing the plot. Just like a gripping book, a well-structured project invites you in and makes it hard to leave.

Continue Learning

Discover more articles on similar topics