Guidelines Software Architecture - global-121/121-platform GitHub Wiki

This page contains the guidelines we have for 121 Service (and mock-service) development.The intended audience is anyone who wants to develop for the 121-platform. The current code base does not follow all these guidelines, but the intent is that any new code or refactoring follows these guidelines.

Domain Abbreviation

Fsp: Financial Service Provider
A Financial Service Provider (Fsp) is an external organization or system integrated with the 121 Platform to facilitate financial transactions, such as payments or voucher distributions, to registrations.

Note: Abbreviations are not used for other domain concepts except those explicitly listed here. All other names should be written in full throughout the codebase.

Guidelines

A. NestJS Module Dependency Structure and Hierarchy

  1. Single Responsibility Principle (SRP): Each module should have a single responsibility or purpose. This makes your code base more modular and easier to maintain.
  2. Minimal Coupling: Modules should be loosely coupled to promote re-usability and easier testing. Avoid tight coupling between modules, as it can lead to dependencies that are difficult to manage.
  3. Hierarchical Structure: Organize modules in a hierarchical manner, with higher-level modules depending on lower-level ones. This helps in maintaining a clear structure and promotes encapsulation.
    1. Higher-Level Modules: These modules typically encapsulate broader functionality or business logic. They often depend on lower-level modules to provide specific implementations. Higher-level modules are usually closer to the application's entry point and orchestrate the interaction between different parts of the system.
    2. Lower-Level Modules: These modules are more focused on specific tasks or functionalities and provide the building blocks for higher-level modules. They tend to have fewer external dependencies and can be reused across different parts of the application. Lower-level modules often implement generic functionalities, such as database access, logging, or utility functions. They are typically more abstract and independent of the application's specific domain logic.
  4. Considerations to determine the hierarchical level of a module:
    1. Check Dependencies: Modules with many dependencies are often higher level, as they rely on other modules to provide functionality.
    2. Abstraction Level: Consider the level of abstraction the module provides. Lower-level modules tend to offer more generic functionality, while higher-level modules deal with specific application features or business logic.
    3. Responsibilities: Modules with broader responsibilities, such as orchestrating interactions between multiple parts of the system or implementing complex business logic, are usually higher level. Modules with narrower responsibilities, such as handling data access or providing utility functions, are typically lower level.
    4. Application Context: Analyze where the module fits within the overall architecture of the application. Modules closer to the entry point or responsible for coordinating interactions between different parts of the system are likely higher level.
  5. Aim for Feature Modules: Group related functionality into feature modules. This makes it easier to reason about the code and promotes re-usability.
  6. Avoid Circular Dependencies: Circular dependencies can lead to runtime errors and make the codebase difficult to understand. Keep an eye on module dependencies to avoid circular references.

B. NestJS Module Implementation

  1. Every Module has a single and clearly defined responsibility.
  2. All database interactions are in Repositories.
  3. A NestJS Module only uses Repositories that belong to their own and Modules lower in the NestJS Module Hierarchy.
  4. Functions do not accept or return Entities.
  5. Enums: each enum in own file. 1 file = 1 enum.
  6. Consider adding suffix OrThrow to each method where you deliberately throw an error. See Function Signatures below too.
  7. Put static function that are used in multiple places in the code in separate 'utils' module. Reference: https://stackoverflow.com/a/40547841
  8. When a service is needed from another module:
    1. Import the full module under 'modules', not just the service under 'providers'
    2. The above also applies for repositories
    3. This way there is no need to import entities in other modules

C. Function signatures

  1. Consider adding OrThrow as the suffix of a function name when a function can throw an 'expected' error. For example, when a Get function should always return an Entity, and it throws an error in case nothing was found in the database, then name the function like getSomethingOrThrow(). You don't always need to do this. Don't use other suffixes like OrFail.
  2. Functions that only return data from the 121 Platform itself, start with "get", not with "find". For example, a function in a Custom Repository that gets data from the database, is named like getXByY(). For example, if a function gets data from an external system, it can be named like retrieveZ().
  3. TBD if/how we consistently name functions that save, store, and create things, like Entities. Some research is needed if we want to make a conceptual difference between creating Entities vs. "other things", if that results in different naming conventions for Services, Custom Repositories etc., and if/how we want to align with TypeORM naming conventions.
  4. Use interfaces for complex input of public functions in exported services (=shared between modules).
    1. Use a "Params" suffix.
    2. In case the interface contains complex type properties of itself, then define these in-line, unless they are re-used across multiple interfaces. e.g. ContactInformation. If the partial is also used on its own, then add the "Params" suffix.
    3. Name the file according to the name of the interface: contact-information-params.interface.ts
    4. Use /interfaces folder.
    5. If there are many interfaces, consider dividing them logically into sub-folders.
    6. See function signature example here. Note: do not add an "input." definition for the object.
    7. All attributes of an interface are defined as "readonly" => see AI for pros and cons.
  5. Use interfaces for complex return interfaces of public functions in exported services (=shared between modules).
    1. Use "Result" suffix.
    2. Name the file according to the name of the interface: contact-information-result.interface.ts
    3. Use /interfaces folder.
    4. If there are many interfaces, consider dividing them logically into sub-folders.
    5. All attributes of an interface are defined as "readonly" => see AI for pros and cons.
  6. For "module internal" methods and private methods:
    1. For 1 or 2 input params, define them in-line
    2. For 3+ params, define them in-line as destructered object. See function signature example here. Note: do not add an "input." definition for the object.
    3. Use common sense, sometimes for 2 params an object already makes sense, because that makes naming the parameters explicit for the caller.

D. Ordering functions in a class (Clean Code)

  1. Functions should be organized in a "step-down" approach, where high-level, more abstract functions appear first, followed by lower-level details. This allows the reader to grasp the overall purpose of the code before diving into its implementation specifics.

    • Top-Level Functions: Should focus on the "what" (i.e., the high-level functionality or business logic).
    • Lower-Level Functions: Should focus on the "how" (i.e., details of implementation or helper methods).

    By placing higher-level methods at the top, a reader can understand the module's behavior at a glance, without immediately having to dig into the details.

  2. Martin suggests that functions should appear in the order they are called. This means that if function A calls function B, function A should appear before function B in the source file. This makes reading the code more intuitive because it flows naturally from the high-level concept (function A) down to the supporting details (function B).

  3. Functions that are closely related should be placed close to each other. This helps reduce the need to scroll or jump around the file, making the code easier to follow. The idea is to minimize the cognitive load on the reader by keeping related operations together.

  4. Private or helper functions should be placed near the public functions they support. This proximity reinforces their role as implementation details and keeps them in context with the public interface they assist.

  5. Code should be structured in a way that prevents the reader from having to jump back and forth between distant parts of the file. Functions that belong together logically should be grouped together physically, which also ties into the principle of keeping related functions close.

E. 121 Service API Request and Response Bodies

  1. Use classes as required by NestJS. Suffix class name with "Dto".
  2. In case the DTO contains complex type properties of itself, then these have be defined as separate class, not in-line, because otherwise you cannot annotate them with Swagger decorators. If the class is also used stand-alone, then put it in a separate file and give it the "Dto" suffix.
  3. Use /dtos folder.
  4. Every DTO and Partial DTO goes into its own file. Name the file according to the name of the DTO.
  5. If there are many DTOs, consider dividing them logically into sub-folders.
  6. Input DTOs: start with verb to indicate the related action it is used in, unless the DTO is used in multiple actions, then omit the verb. Example: CreateAddressDto would be used to create an address, UpdateAddressDto to update it. If the DTOs are identical, it will be 1 DTO called AddressDto.
  7. Output DTOs:
    1. For sending data in an API response: suffix with "Response".
    2. Leave the prefix verb in Response DTO: only when used for that single action. If used in multiple actions, then omit the verb. E.g. if we have CreateUserDto and UpdateUserDto and they both return the same User data, then it will be UserResponseDto. If they have different return data, then they would be CreateUserResponseDto and UpdateUserResponseDto.
  8. All attributes of a DTO are defined as "readonly" => see AI for pros and cons.
  9. Both for request DTOs and response DTOs: use appropriate decorators.

F. Request and Response Bodies when using 3rd party APIs (e.g. Intersolve API)

  1. Use interfaces.
  2. Name the interfaces like we name DTOs for our 121 Service API. Including the Dto suffix.
  3. Use the following naming convention for external API DTOs using this format: {Fsp-name}Api{Operation}{Request|Response}{Body|Headers} and filename {fsp-name}-api-{operation}-{request|response}-{body|headers}.dto.ts. Some examples: AirtelApiDisbursementRequestHeaders and airtel-api-disbursement-request-headers.dto.ts, orAirtelApiAuthenticationResponseBody and airtel-api-authentication-response-body.dto.ts. Use the same format for DTO partials.
  4. Put all DTOs for one 3rd party API into its own sub folder under the /dtos folder. For example: /dtos/safaricom-api/
  5. Do not share (partial) DTOs for communication with a 3rd party API and for the 121 Service API, for clear separation of concerns.

G. Use URL and Header objects for constructing and manipulating URLs and Headers

  1. Does not apply when framework-specific tooling exists, like Angular HTTPClient.
  2. When constructing URLs, URL parameters or Headers for use with fetch use the available interfaces/objects for them: URL and Headers .
  3. When calling fetch: pass the URL object instance.
  4. When calling fetch: set the Headers object instance as the value for the headers property of the option object.

H. API Design

  1. Organize API around entities and not use cases
  2. Apply proper HTTP methods:
    1. GET/POST/DELETE/PUT/PATCH
    2. Don't use POST for GET to be able to pass payload/body
  3. Apply proper status codes in response:
    1. Document all specifically relevant status codes per endpoint
  4. A 404 Not Found response is used on a GET call to a resource endpoint (e.g. /api/activities/3) that does not exist.
  5. A 200 OK response with an empty array as body is used on a GET call to a collections endpoint with no resources under it (e.g. /api/activities when there are no activities available).
  6. Naming:
    1. Don't use verbs, only nouns; there can be exceptions, for example for tasks that go beyond simple manipulation of a resource, like retrying or approving a payment. In that case use /retry or /approve at the end.
    2. Use plural/singular per best practice
    3. Use IDs in names as per this table
    4. Limit to 2 levels in naming ("/noun/id/noun/id") > figure out how this works in practice
  7. Limit response to 2 levels deep (so relation of a relation is still OK)
  8. Use same endpoint with query-parameter for response format options (e.g. export vs json for PA-table)

I. Naming

  1. Name things with their full name, do not use abbreviations. Let your IDE auto-complete names, so no RSI because of long names.
  2. Class names are plural for Modules, Controllers and Services. For example: ProgramsModule, ProgramsService, ProgramsController. Reflected in the file names as well. For example: programs.module.ts, programs.service.ts, programs.controller.ts.
  3. Class names are singular for Entities and Repositories. For example: ProgramEntity, with file name: program.entity.ts. And: FinancialServiceProviderRepository, with file name: fsp.repository.ts.
  4. Base folder names of a Module are plural. For example: src/programs/
  5. Do not include Enum suffix for Enums, so e.g. DefaultUserRole, not DefaultUserRoleEnum.

J. Data Model and Entities

  1. Use 3NF: 3rd Normal Form. Reference: https://en.wikipedia.org/wiki/Third_normal_form
  2. An Entity is defined in and "belongs to" a NestJS Module.
  3. Only this NestJS Module and NestJS Modules that depend on this NestJS Module use (import) the Entity class. In other words: Entity dependency follows (/is same as) NestJS Module dependency.
  4. All data access is done via (Custom) Repositories, which also is defined in and belongs to this NestJS Module.
  5. Data access functionality is encapsulated in Custom Repositories, so that no TypeORM-specific code lives outside of these. Exception: simple TypeORM clauses related to .find and .count functions can be used in Services code. See count example here.
  6. An Entity can only be created, updated and deleted within this NestJS Module.
  7. When getting data of Entities with related Entities: the functionality goes in the repository of the "base" Entity. For example, when retrieving Program data with all of its Registrations, there would be a function in the ProgramsRepository. But when only retrieving all Registrations of a Program (so without Program data itself), there would be a function in the RegistrationsRepository. Reason: this is a "weird" consequence of using 3NF together with NestJS Module Hierarchy: in the database the FK goes into the "lower" table.
  8. Create fewer more generic functions that are called from many places in Custom Repositories over creating many specific functions that are called only from one place. Refactor if needed. There is no hard rule to this, it is more art than science. But we do not want to end up with Custom Repositories that have dozens of "get" functions with very specific function signatures that each are called only from one place.
  9. Properties in entities and DTOs should
    1. not use JSON as their TS type
    2. include | null as part of their TS type when they are nullable: true
  10. In Entity definition: if a column must contain a value, then do not add the "nullable: true" property (I have seen this in various places)
  11. Entities: for FK object properties, use the full name of the foreign entity. For example, IntersolveVisaCustomer.intersolveVisaParentWallet and not IntersolveVisaCustomer.parentWallet.