Propuesta de una aplicación bancaria con arquitectura basada en microservicios

Ver en GitHub

En la arquitectura basada en microservicios, las aplicaciones se crean como un conjunto de servicios que se administran de forma independiente. Esto se traduce en una ventaja con respecto a las aplicaciones monolíticas en el sentido de que son aplicaciones más fáciles de mantener y escalar.

En esta publicación presento mi propio enfoque de una aplicación bancaria, con una arquitectura basada en microservicios y utilizando el stack MERN, que puede administrar cuentas, clientes y préstamos, pero también hacer predicciones de posibles incumplimientos de préstamos y almacenar datos en cadena.

En este artículo

Perspectiva general

El propósito de esta aplicación bancaria ficticia es desarrollar una interfaz de usuario que permita a los empleados crear, actualizar y eliminar cuentas, clientes y préstamos, almacenar préstamos en cadena y hacer predicciones sobre posibles incumplimientos de préstamos.

Por lo tanto, esta aplicación se compone de múltiples servicios que pueden o no comunicarse entre sí para realizar una acción con éxito.

Para construir la aplicación, he desarrollado un monorepositorio utilizando Turborepo y pnpm workspaces. Cada microservicio se ha dockerizado para que cada servicio viva en su propio contenedor.

Las carpetas packages y services reúnen todos los espacios de trabajo.

bank-microservices
├── packages
   ├── bank-utils
   └── ...
   ├── eslint-config-custom
   └── ...
   ├── eslint-config-custom-next
   └── ...
   ├── eslint-config-custom-subgraph
   └── ...
   ├── logger
   └── ...
   ├── server
   └── ...
   └── ts-config
       └── ...
├── services
   ├── accounts
   └── ...
   ├── client
   └── ...
   ├── customers
   └── ...
   ├── graph-node
   └── ...
   ├── loans
   └── ...
   ├── loans-model
   └── ...
   └── requests
       └── ...
├── docker-compose.yaml
├── package.json
└── ...

En packages podemos encontrar paquetes npm que contienen código y archivos de configuración que son utilizados por otros paquetes o servicios, y services reúne el conjunto de microservicios que conforman la mayor parte de la aplicación.

Los servicios accounts, customers, loans , loans-model y requests inician una aplicación de servidor web, client inicia una aplicación de Next.js y en graph-node se almacenan datos derivados de un nodo de TheGraph.

Además, dos servicios, customers y loans, también incluyen un entorno de Hardhat y Foundry para desarrollar, testear e implementar contratos.

Por último, las aplicaciones de back end y front end se implementan en base a una arquitectura hexagonal.

Microservicios

Los microservicios que incluye esta aplicación son una colección de servicios granulares que mayormente funcionan de manera independiente aunque algunos de ellos tienen una cierta dependencia con otros servicios.

Por ejemplo, los servicios customers y loans empezarán a funcionar satisfactoriamente cuando el nodo de TheGraph esté activo para, de esta manera, poder desplegar sus propios subgrafos y así client pueda consumir los datos.

Además, todos los servicios que ejecutan un servidor de Express.js dependen del acceso a diferentes bases de datos de MongoDB que residen en contenedores de Docker separados.

Por lo demás, solamente el cliente será el que se comunique con los demás servicios para poder completar sus funciones.

Los servicios que componen esta aplicación y sus principales características son los siguientes:

  • accounts: este microservicio se encarga de todas las acciones que estén relacionadas con las cuentas en el banco: crear, eliminar, actualizar y obtener cuentas, depositar en cuentas y extraer de cuentas.

  • client: se trata de una aplicación de Next.js que un usuario puede utilizar para ejecutar las acciones pertinentes con las que interactuar con el resto de microservicios. Puede encontrar más información en interfaz de usuario.

  • customers: en este servicio se implementan todas las acciones que tengan que ver con los clientes del banco: crear, eliminar, actualizar y obtener clientes.

  • graph node: en este workspace se escribirán los datos de la base de datos de PostgresSQL y de la red IPFS que utiliza el nodo de TheGraph para su correcto funcionamiento.

    La configuración del nodo junto con la del resto de las configuraciones de las imágenes de Docker en esta aplicación se encuentran en el archivo /docker-compose.yaml.

  • loans: este microservicio se encarga de todas las acciones que estén relacionadas con los préstamos: crear, eliminar, actualizar y obtener préstamos.

  • loans model: en este servicio se implementa un pipeline formado por objetos transformadores y una red neuronal, que conforman un flujo de trabajo para clasificar, de manera binaria, préstamos y asi predecir si pueden o no incurrir en default. Puede encontrar más información en modelo de clasificación.

  • requests: este servicio existe para representar unas hipotéticas peticiones de préstamos por parte de clientes, a partir de las cuales poder obtener información para interactuar con otros servicios.

En los microservicios, por lo tanto, se pueden encontrar implementaciones de servicios web Express.js con acceso a bases de datos MongoDB independientes, también entornos de Hardhat y Foundry para el desarrollo de contratos inteligentes, subgrafos para la indexación de datos de los eventos en estos contratos, un servicio web Flask para interactuar con un modelo de clasificación binario y una aplicación de Next.js para dar forma a una interfaz de usuario.

En las siguientes secciones se introducen todas estas características haciendo referencia a uno de los servicios para adjuntar código como ejemplo.

Back end

Se han desarrollado APIs versionadas para cuatro de los microservicios implementados en la aplicación, de tal manera que mediante un servidor de Express.js se puedan crear, leer, actualizar y eliminar datos persistentes.

La arquitectura utilizada para todas ellas es hexagonal. A continuación, se introducirán las características principales de la API desarrollada para el microservicio loans.

Estructura de archivos

La base sobre la cuál se estructuran los archivos es la siguiente:

bank-microservices
├── services
   ├── loans
   ├── api
   └── v1
       ├── controllers
   └── ...
       ├── core
   ├── interactors
   └── ...
   └── repositories
       └── ...
       ├── data-sources
   └── ...
       ├── index.ts
       └── ...
   └── ...
   └── ...
└── ...

El servidor se instanciará en el archivo index.ts pasándole un objeto con información sobre las rutas, una instancia de un WinstonLogger y de manera opcional otros datos de configuración.

Tanto la implementación del logger como del servidor se encuentran en /packages.

services/loans/api/v1/index.ts
const logger: IWinstonLogger = new WinstonLogger();
 
const deleteByIdPath = join(deletePath, ':identifier');
const getByIdPath = join(getPath, ':identifier');
 
const routesData: RouteData[] = [
  // Valid requests
  { method: createMethod, path: createPath, handler: createLoanController },
  { method: deleteByIdentifierMethod, path: deleteByIdPath, handler: deleteLoanByIdentifierController },
  { method: getAllMethod, path: getAllPath, handler: getAllLoansController },
  { method: getByIdentifierMethod, path: getByIdPath, handler: getLoanByIdController },
  { method: updateMethod, path: updatePath, handler: updateLoanController },
  // Invalid methods
  { method: 'all', path: createPath, handler: handleInvalidMethodController, isSync: true },
  { method: 'all', path: deleteByIdPath, handler: handleInvalidMethodController, isSync: true },
  { method: 'all', path: getAllPath, handler: handleInvalidMethodController, isSync: true },
  { method: 'all', path: getByIdPath, handler: handleInvalidMethodController, isSync: true },
  { method: 'all', path: updatePath, handler: handleInvalidMethodController, isSync: true },
  // Unrecognized URLs
  { method: 'all', path: '*', handler: handleNotFoundController, isSync: true }
];
 
// const config: Config = {
//   cache: { duration: '5 minutes', onlySuccesses: true },
//   cors: {
//     origin: `http://localhost:${LoansConfigV1.CLIENT_PORT ?? 3000}`,
//     optionsSuccessStatus: 200
//   }
// };
 
async function main(): Promise<void> {
  new ExpressApi(logger, routesData).start(LoansConfigV1.PORT ?? '3003');
}
 
main().catch((error) => {
  logger.error(error);
  process.exit(0);
});

Al ejecutar este script, el servidor estará disponible en el puerto que se le pasa como argumento y que vendrá asignado en un archivo de variables de entorno.

En el esquema de archivos que vimos más arriba vemos que la API se compone de tres carpetas:

  • controllers: se encargan de definir las operaciones deseadas por la API de la aplicación. Por ejemplo, al hacer un fetch al servidor de loans en la ruta /v1/create con el método POST el servidor asignará el siguiente controlador para que se encargue de realizar las acciones deseadas:

    services/loans/api/v1/controllers/create-loan.controller.ts
    export const createLoanController = async ({ body }: Request, response: Response): Promise<void> => {
      const { loan } = body;
     
      // if body is not an instance of Loan
      if (!isInstanceOfLoan(loan)) {
        response.status(StatusCodes.BAD_REQUEST).json({ success: false, error: true, message: loanBadRequestMessage, data: null });
      }
      // If request is valid, call the interactor
      else {
        const { success, ...rest } = await createLoanWithDep(loan);
        // If the request succeeded
        if (success) {
          response.status(StatusCodes.OK).json({ success, ...rest });
        }
        // If the request did not succeed
        else {
          response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ success, ...rest });
        }
      }
    };

    Como podemos ver, en caso de que el body de la petición sea una instancia de la entidad Loan, es decir, si la petición es válida, se ejecutará un interactor al cuál ya se le ha inyectado el repositorio de préstamos como una abstracción de una clase de fuente de datos, donde se implementa ese repositorio.

    Las entidades, al igual que otros útiles que son compartidos entre varios servicios se implementan en el paquete bank-utils. Por ejemplo, la entidad Loan importada desde allí se puede ver a continuación:

    packages/bank-utils/
    export interface Loan {
      creditPolicy: boolean;
      customerId: number;
      delinquencies2Yrs: number;
      daysWithCreditLine: number;
      identifier: number;
      installment: number;
      intRate: number;
      isDefault: boolean;
      purpose: string;
      revolBal: number;
      revolUtil: number;
    }

    Abajo tenemos un diagrama en el cual se indica el flujo de código al ejecutar el controlador createLoanController:

    Por lo tanto, el resultado será la obtención de una objeto de tipo InteractorResponse, el cual nos dará información sobre el estado de la petición así como mensajes y datos.

    De esta manera, se dará conformidad a la respuesta oportuna por parte del servidor en cada caso, como podemos ver en la implementación de createLoanController en las líneas 6, 13 y 17.

  • core: la carpeta core, o núcleo, recopila la lógica de negocio de cada servicio. Se compone de repositorios e interactores. Como vimos anteriormente, el controlador invocará al menos un caso de uso, o interactor, que interactúa con un repositorio para obtener una entidad.

    • repositorios: son interfaces que definen todos los métodos que son necesarios para lidiar con todos los casos de uso de cada servicio.

      Estos métodos se implementan en una clase de fuente de datos fuera del núcleo.

      Por ejemplo, podemos ver parte del repositorio LoanRepository a continuación:

      services/loans/api/v1/core/repositories/loan.repository.ts
      interface LoanRepository {
        /**
        * Connect to MongoDB using a url.
        */
        connect: () => Promise<void>;
       
        /**
        * Close the client and its underlying connections.
        */
        close: () => Promise<void>;
       
        /**
        * Create a loan.
        * @param loan `Loan` entity object.
        */
        create: (loan: Loan) => Promise<void>;
       
        // ...
      }
       
      export default LoanRepository;
    • interactores: en los interactores se implementan los casos de uso del servicio correspondiente.

      Los controladores utilizan interactores que ya tienen las dependencias inyectadas para mantener la lógica de negocio independiente de la infraestructura.

      Por ejemplo, anteriormente vimos en controladores que se utilizaba el interactor createLoanWithDep, el cual ya tiene inyectada su dependencia como podemos ver abajo:

      services/loans/api/v1/core/interactors/index.ts
      const loanRepository = new LoanDataSource();
       
      const createLoanWithDep = createLoan(loanRepository);
      const deleteLoanByIdWithDep = deleteLoan(loanRepository);
      const getLoanByIdWithDep = getLoanById(loanRepository);
      const getAllLoansWithDep = getAllLoans(loanRepository);
      const handleInvalidMethodWithDep = handleInvalidMethod(loanRepository);
      const handleNotFoundWithDep = handleNotFound(loanRepository);
      const updateLoanWithDep = updateLoan(loanRepository);
       
      export {
        createLoanWithDep,
        deleteLoanByIdWithDep,
        getLoanByIdWithDep,
        getAllLoansWithDep,
        handleInvalidMethodWithDep,
        handleNotFoundWithDep,
        updateLoanWithDep
      };

      La implementación del interactor createLoan se puede ver a continuación:

      services/loans/api/v1/core/interactors/create-loan.interactor.ts
      const createLoan =
        (repository: LoanRepository) =>
        async (loan: Loan): Promise<InteractorResponse> => {
          try {
            await repository.connect();
            await repository.create(loan);
            return { success: true, error: false, message: 'A loan has been succesfully created.', data: null };
          } catch (error) {
            repository.log('error', error);
            if (error instanceof Error) {
              const { message } = error;
              return { success: false, error: true, message, data: null };
            } else {
              return { success: false, error: true, message: errorMessage, data: null };
            }
          } finally {
            try {
              await repository.close();
            } catch (error) {
              if (error instanceof Error) {
                repository.log('warn', error.stack ?? error.message);
              } else {
                repository.log('warn', clientClosingErrorMessage);
              }
            }
          }
        };
       
      export default createLoan;

      Como podemos ver, este interactor espera una entidad LoanRepository que se utiliza para llamar a cuatro de sus métodos (log, connect, create y close), pero el interactor no depende de su implementación.

  • data sources: como ya dijimos, los métodos que se describen en el repositorio se implementan en una clase en la carpeta data-sources, o fuentes de dato.

    Podemos ver a continuación la implementación de los cuatro métodos citados anteriormente y que se utilizan en el interactor createLoan:

    services/loans/api/v1/data-sources/loan.data-source.ts
    class LoanDataSource implements LoanRepository {
      private client: MongoClient | null;
      private readonly logger: IWinstonLogger;
     
      constructor() {
        this.client = null;
        this.logger = new WinstonLogger();
      }
     
      public async connect(): Promise<void> {
        if (typeof DB_PORT === 'undefined' || typeof DB_USER === 'undefined' || typeof DB_PASSWORD === 'undefined' || typeof DB_URI === 'undefined') {
          throw new Error('Cound not read configuration data');
        }
        const URI = typeof process.env.NODE_ENV !== 'undefined' && process.env.NODE_ENV === 'development' ? `mongodb://localhost:${DB_PORT}` : DB_URI;
        this.client = new MongoClient(URI, { auth: { username: DB_USER, password: DB_PASSWORD } });
        await this.client.connect();
      }
     
      public async close(): Promise<void> {
        if (this.client !== null) {
          await this.client.close();
          this.client = null;
        }
      }
     
      public async create({ identifier, ...rest }: Loan): Promise<void> {
        const loans = this.getCollection();
        const loan = await loans.findOne<Loan>({ identifier });
        if (loan !== null) {
          throw new Error(`There is already a loan with the identifier: ${identifier}`);
        }
        await loans.insertOne({ identifier, ...rest });
      }
     
      public log(type: 'info' | 'warn' | 'error', message: any): void {
        if (type === 'info') {
          this.logger.info(message);
        } else if (type === 'warn') {
          this.logger.warn(message);
        } else {
          this.logger.error(message);
        }
      }
      // ...
    }

Contratos inteligentes

En el ámbito financiero existen numerosas oportunidades donde hacer uso de las ventajas de las cadenas de bloques. Y es que la descentralización de los datos en esta tecnología aseguran la seguridad e inmutabilidad que se requiere para poder ahorrarse otros trámites burocráticos.

Por ejemplo, requisitos que se imponen sobre los prestadores por distintas regulaciones precisan de mucho tiempo para que se agilicen los trámites y los fondos se presten.

En el caso de que estos trámites pudiesen agilizarse compartiendo de forma segura información entre entidades crediticias aprobadas, se podría reducir el tiempo de decisión sobre el crédito de manera sustancial.

En esta aplicación ficticia se han escrito unos contratos muy sencillos en los cuales se implementan los métodos necesarios para almacenar y actualizar tanto préstamos como clientes.

A continuación podemos ver las funciones del contrato LoanManager:

services/loans/contracts/src/LoanManager.sol
contract LoanManager is Ownable {
    // ...
    /**
     * Add a loan.
     * @param identifier loan identifier.
     * @param customerId customer id.
     * @param purpose purpose of the loan.
     * @param intRate interest rate.
     * @param installment installment.
     * @param delinquencies2Yrs delinquencies in the last 2 years.
     * @param isDefault bool that represents whether this loan is default.
     */
    function addLoan(
        uint256 identifier,
        uint256 customerId,
        bytes32 purpose,
        bytes32 intRate,
        bytes32 installment,
        uint256 delinquencies2Yrs,
        bool isDefault
    ) public onlyOwner {
        _loanIdToLoan[identifier].isLoan = true;
        _loanIdToLoan[identifier].customerId = customerId;
        _loanIdToLoan[identifier].purpose = purpose;
        _loanIdToLoan[identifier].intRate = intRate;
        _loanIdToLoan[identifier].installment = installment;
        _loanIdToLoan[identifier].delinquencies2Yrs = delinquencies2Yrs;
        _loanIdToLoan[identifier].isDefault = isDefault;
        emit LoanAdded(identifier, customerId, purpose, intRate, installment, delinquencies2Yrs, isDefault);
    }
 
    /**
     * Update a loan.
     * @param identifier loan identifier.
     * @param purpose purpose of the loan.
     * @param intRate interest rate.
     * @param installment installment.
     * @param delinquencies2Yrs delinquencies in the last 2 years.
     * @param isDefault bool that represents whether this loan is default.
     */
    function updateLoan(
        uint256 identifier,
        bytes32 purpose,
        bytes32 intRate,
        bytes32 installment,
        uint256 delinquencies2Yrs,
        bool isDefault
    ) public onlyOwner {
        require(_loanIdToLoan[identifier].isLoan == true, 'Loan does not exist.');
        _loanIdToLoan[identifier].purpose = purpose;
        _loanIdToLoan[identifier].intRate = intRate;
        _loanIdToLoan[identifier].installment = installment;
        _loanIdToLoan[identifier].delinquencies2Yrs = delinquencies2Yrs;
        _loanIdToLoan[identifier].isDefault = isDefault;
        emit LoanUpdated(identifier, purpose, intRate, installment, delinquencies2Yrs, isDefault);
    }
 
    /**
     * Get loan data by identifier.
     * @param identifier loan identifier.
     * @return isLoan bool that represents whether this loan has been added.
     * @return customerId customer id.
     * @return purpose purpose of the loan.
     * @return intRate interest rate.
     * @return installment installment.
     * @return delinquencies2Yrs delinquencies in the last 2 years.
     * @return isDefault bool that represents whether this loan is default.
     */
    function getLoanById(
        uint256 identifier
    )
        external
        view
        returns (
            bool isLoan,
            uint256 customerId,
            bytes32 purpose,
            bytes32 intRate,
            bytes32 installment,
            uint256 delinquencies2Yrs,
            bool isDefault
        )
    {
        Loan memory loan = _loanIdToLoan[identifier];
        return (
            loan.isLoan,
            loan.customerId,
            loan.purpose,
            loan.intRate,
            loan.installment,
            loan.delinquencies2Yrs,
            loan.isDefault
        );
    }
}

Testeando los contratos

Como ya se ha mencionado, también existe un entorno de Foundry para poder testear los contratos de una manera muy intuitiva y rápida utilizando lenguaje Solidity.

A continuación podemos ver parte del contrato de test para testear las funciones del contrato LoanManager:

services/loans/contracts/test/foundry/LoanManager.t.sol
contract LoanManagerTest is Test {
    // ...
    function setUp() public {
        debtorsData = new LoanManager();
    }
 
    function testAddLoan() public {
        vm.expectEmit(true, true, true, true);
        emit LoanAdded(_loanId, _customerId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
        debtorsData.addLoan(_loanId, _customerId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
        (bool isLoan, , , , , , ) = debtorsData.getLoanById(_loanId);
        assertEq(isLoan, true);
    }
 
    function test_RevertedWhen_CallerIsNotOwner_AddLoan() public {
        vm.expectRevert('Ownable: caller is not the owner');
        vm.prank(address(0));
        debtorsData.addLoan(_loanId, _customerId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
    }
 
    function testUpdateLoan() public {
        debtorsData.addLoan(_loanId, _customerId, _purpose, _intRate, _installment, _delinquencies2Yrs, false);
        vm.expectEmit(true, true, true, true);
        emit LoanUpdated(_loanId, _purpose, _intRate, _installment, _delinquencies2Yrs, true);
        debtorsData.updateLoan(_loanId, _purpose, _intRate, _installment, _delinquencies2Yrs, true);
        (, , , , , , bool isDefault) = debtorsData.getLoanById(_loanId);
        assertEq(isDefault, true);
    }
 
    function test_RevertedWhen_LoanDoesNotExist_UpdateLoan() public {
        vm.expectRevert('Loan does not exist.');
        debtorsData.updateLoan(_loanId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
    }
 
    function test_RevertedWhen_CallerIsNotOwner_UpdateLoan() public {
        vm.expectRevert('Ownable: caller is not the owner');
        vm.prank(address(0));
        debtorsData.updateLoan(_loanId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
    }
 
    function testGetLoanById() public {
        (bool isLoanWithDefaultValues, , , , , , ) = debtorsData.getLoanById(_loanId);
        assertEq(isLoanWithDefaultValues, false);
        debtorsData.addLoan(_loanId, _customerId, _purpose, _intRate, _installment, _delinquencies2Yrs, _isDefault);
        (bool isLoanAfterAddition, , , , , , ) = debtorsData.getLoanById(_loanId);
        assertEq(isLoanAfterAddition, true);
    }
}

Subgrafos

Los microservicios customers y loans implementan subgrafos utilizando TheGraph, los cuales se pueden desplegar a un nodo de TheGraph y así consumir datos desde un cliente de manera eficiente.

Dado que los datos que se van a utilizar no pueden ser públicos y dada la naturaleza de la aplicación, tal vez sería más apropiado desplegar los contratos a una cadena de bloques privada que el cifrado de los datos.

De todos modos, he desarrollado subgrafos utilizando TheGraph -que no es compatible con todas las cadenas de bloques ni redes- debido a su facilidad de uso y eficiencia desde la perspectiva de la aplicación que consume los datos.

Para crear estos subgrafos existen tres partes fundamentales: el manifiesto, el esquema GraphQL y los mappings.

Manifiesto del subgrafo

El manifiesto del subgrafo especifica qué contratos indexa el subgrafo, a qué eventos reaccionar y cómo se asignan los datos de eventos a entidades que el nodo de TheGraph almacena y permite consultar.

Abajo se muestra el manifiesto para el microservicio de loans:

services/loans/subgraph/templates/subgraph.template.yaml
specVersion: 0.0.4
description: Bank Microservices Loans
repository: https://github.com/albertobas/bank-microservices/services/loans
schema:
    file: ./schema.graphql
features:
    - ipfsOnEthereumContracts
dataSources:
    - name: LoanManagerDataSource
      kind: ethereum/contract
      network: {{network}}
      source:
          address: "{{LoanManagerAddress}}"
          abi: LoanManager
          startBlock: {{LoanManagerStartBlock}}
      mapping:
          kind: ethereum/events
          apiVersion: 0.0.6
          language: wasm/assemblyscript
          file: ./src/mappings/LoanManager.ts
          entities:
              - Account
              - LoanManagerContract
              - Loan
              - LoanAddition
              - LoanUpdate
              - OwnershipTransferred
              - Transaction
          abis:
              - name: LoanManager
                file: ../contracts/deployments/{{network}}/LoanManager.json
          eventHandlers:
              - event: LoanAdded(indexed uint256,indexed uint256,bytes32,bytes32,bytes32,uint256,bool)
                handler: handleLoanAdded
              - event: LoanUpdated(indexed uint256,bytes32,bytes32,bytes32,uint256,bool)
                handler: handleLoanUpdated
              - event: OwnershipTransferred(indexed address,indexed address)
                handler: handleOwnershipTransferred

Las claves que aparecen entre llaves serán reemplazadas a la hora de generar el manifiesto por valores obtenidos del despliegue de los contratos.

Esquema GraphQL

Teniendo en cuenta la simplicidad de los contratos cuyos eventos se desean indexar, se ha elaborado un esquema relativamente denso y entrelazado.

Para ello se distingue entre la creación de entidades para:

  • conceptos de alto nivel: conceptos como la entidad Loan o LoanManagerContract.

    services/loans/subgraph/schema.graphql
    type LoanManagerContract @entity {
      id: Bytes!
      asAccount: Account!
      owner: Account
      loans: [Loan!]! @derivedFrom(field: "contract")
      loanAdditions: [LoanAddition!]! @derivedFrom(field: "contract")
      loanUpdates: [LoanUpdate!]! @derivedFrom(field: "contract")
    }
  • conceptos de bajo nivel: eventos que emiten los contratos.

    services/loans/subgraph/schema.graphql
    type LoanAddition implements IEvent @entity(immutable: true) {
      id: ID!
      contract: LoanManagerContract!
      emitter: Account!
      loan: Loan!
      timestamp: BigInt!
      transaction: Transaction!
    }

Mappings

Mediante las funciones definidas como mappings en el manifiesto, TheGraph asigna datos que va obteniendo de los eventos del contrato a entidades definidas en el esquema.

Por ejemplo, la siguiente función se encarga de escribir varias entidades con los datos procesados en el store del nodo de TheGraph posteriormente a que el contrato emita el evento LoanAdded.

services/loans/src/mappings/LoanManager.ts
export function handleLoanAdded(event: LoanAdded): void {
  let loan = ensureLoan(event.params.identifier, event.address, event.block.timestamp);
  let addition = registerLoanAddition(event, loan);
  loan.addition = addition.id;
  loan.customer = event.params.customerId;
  loan.installment = event.params.installment.toString();
  loan.intRate = event.params.intRate.toString();
  loan.isDefault = event.params.isDefault;
  loan.purpose = event.params.purpose.toString();
  loan.delinquencies2Yrs = event.params.delinquencies2Yrs;
  loan.save();
}

Modelo de clasificación

El microservicio loans-model consta de un servicio web Flask el cual da acceso a un modelo de clasificación.

Podemos predecir si un préstamo puede entrar en default mediante este modelo de clasificación binaria tras un entrenamiento supervisado utilizando una base de datos de préstamos de LendingClub.

Se trata de un set de datos altamente desequilibrado, por lo tanto las predicciones de la clase minoritaria tienen una certeza considerablemente menor a los de la mayoritaria.

No se ha considerado ninguna técnica de replacement dado que el único propósito del ejercicio es ser capaces de realizar predicciones desde la interfaz de usuario.

Se utiliza un pipeline de Scikit-learn el cual integra:

  • una etapa de preprocesamiento con un codificador de variables cualitativas y un objeto transformador para el escalado de variables.

  • el wrapper de Skorch NeuralNetBinaryClassifier para un modelo de red neuronal implementado en PyTorch.

La red neuronal Model y el objeto transformador Encoder los podemos encontrar en /services/loans-model/utils. El modelo utilizado es el siguiente:

services/loans-model/utils/model.py
class Model(nn.Module):
   def __init__(self, num_units, input_size, dropout=0):
       super(Model, self).__init__()
       self.dense0 = nn.Linear(input_size, num_units)
       self.dropout0 = nn.Dropout(dropout)
       self.dense1 = nn.Linear(num_units, num_units)
       self.dropout1 = nn.Dropout(dropout)
       self.dense2 = nn.Linear(num_units, num_units)
       self.dropout2 = nn.Dropout(dropout)
       self.output = nn.Linear(num_units, 1)
 
   def forward(self, X):
       X = F.relu(self.dense0(X))
       X = self.dropout0(X)
       X = F.relu(self.dense1(X))
       X = self.dropout1(X)
       X = F.relu(self.dense2(X))
       X = self.dropout2(X)
       X = self.output(X)
       X = X.squeeze(-1)
       return X

Podemos ver el script para el entrenamiento de este pipeline y su serialización para posterior despliegue a continuación:

services/loans-model/dump_model.py
data = fetch_openml(name="Lending-Club-Loan-Data", version=1,
                    as_frame=True, data_home='data', parser='auto')
frame = data.frame.copy()
y = frame['not.fully.paid'].astype(np.float32)
frame.drop('not.fully.paid', axis=1, inplace=True)
 
 
qualitative = ['purpose']
categories = list(itertools.chain.from_iterable((var + '_' + str(value)
                                                 for value in np.unique(frame[var].dropna()))
                                                for var in qualitative))
 
classifier = NeuralNetBinaryClassifier(
    Model(num_units=64, input_size=frame.shape[1]),
    criterion=BCEWithLogitsLoss,
    optimizer=Adam,
    batch_size=32,
    iterator_train__shuffle=True,
    lr=0.01,
    max_epochs=200)
 
pipe = Pipeline([
    ('encoder', Encoder(categories, qualitative)),
    ('scale', MinMaxScaler()),
    ('classifier', classifier),
])
 
pipe.fit(frame, y)
 
dump(pipe.best_estimator_, 'pipeline.joblib')

He utilizado Skorch porque ofrece la ventaja de envolver un modelo implementado con PyTorch y usarlo con GridSearchCV de Scikit-learn.

De esta manera, se realiza una optimización de hiperparámetros de modelo y entrenamiento evaluada por validación cruzada utilizando tres divisiones, o folds, del set de datos.

Podemos ver abajo un fragmento del cuaderno de Jupyter loans_default.ipynb en el que se muestra esta implementación:

services/loans-model/loans_default.ipynb
params = {
    'classifier__lr': [0.01, 0.005, 0.001],
    'classifier__module__num_units': [32, 64, 90],
    'classifier__batch_size': [32, 64, 128]
}
 
grid_search = GridSearchCV(pipe, params, refit=True,
                           cv=3, scoring='accuracy', verbose=0)
 
grid_result = grid_search.fit(frame, y)
Best mean test score: 0.838, Best std test score: 0.003, Best params: {'classifier__batch_size': 32, 'classifier__lr': 0.01, 'classifier__module__num_units': 32}

Por último, a continuación está el código utilizado para ejecutar la aplicación Flask, junto con la implementación de la función predict para la ruta /predict, que es la única ruta:

services/loans-model/app.py
model = load('pipeline.joblib')
app = Flask(__name__)
cors = CORS(app, resources={
            r"/predict": {"origins": f"http://localhost:{CLIENT_PORT}"}})
 
 
@app.route('/predict', methods=['POST'])
def predict():
    new_obsevation = request.get_json(force=True)
    new_observation_frame = pd.DataFrame([new_obsevation.values()], columns=['credit.policy', 'purpose', 'int.rate', 'installment', 'log.annual.inc',
                                                                             'dti', 'fico', 'days.with.cr.line', 'revol.bal', 'revol.util', 'inq.last.6mths', 'delinq.2yrs', 'pub.rec'])
    prediction = model.predict(new_observation_frame)
    output = prediction[0]
    response = jsonify(str(output))
    return response
 
 
if __name__ == '__main__':
    app.run(port=APP_PORT, debug=True)

Interfaz de usuario

Se ha desarrollado una sencilla aplicación web con Next.js 13 utilizando su nueva feature App Router.

Se trata de una aplicación con cinco rutas y cuatro secciones, en las que simplemente se utilizan unos formularios y unos botones para poder interactuar con los servidores, así como con los contratos inteligentes.

Por ejemplo, en cada sección aparece un tarjeta para cada caso de uso que se implementa en su respectivo microservicio.

A continuación podemos ver el componente funcional en React.js para la creación de un préstamo:

services/client/src/app/loans/_components/create-loan-card.tsx
export function CreateLoanCard(): JSX.Element {
  const [message, setMessage] = useState<string | null>(null);
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({
    resolver: yupResolver(loanSchema),
  });
 
  const onSubmit = handleSubmit(async ({ creditPolicy, isDefault, ...rest }) => {
    const response = await createLoanWithDep({
      creditPolicy: Boolean(creditPolicy),
      isDefault: Boolean(isDefault),
      ...rest,
    } as Loan);
    console.log('RESPONSE:', response);
    setMessage(response.message);
    reset();
  });
 
  return (
    <Card>
      <h2>Create loan</h2>
      <LoanForm onSubmit={onSubmit} register={register} errors={errors} />
      {message !== null && <p>{message}</p>}
    </Card>
  );
}

Se trata de un formulario utilzando React Hook Form con el que se obtienen los datos necesarios para poder formar una entidad Loan y pasarla al interactor createLoanWithDep al pulsar Submit.

A continuación podemos ver el diagrama de flujo de este componente:

Una vez más, los interactores llaman a un método de un repositorio para obtener una entidad, el repositorio, a su vez, se inyecta al interactor como una abstracción de una clase de fuente de datos.

Estructura de archivos

Como podemos ver en el siguiente esquema, esta aplicación de Next.js consiste de la carpeta app que incorpora todos los componentes necesarios en las rutas requeridas, además de una carpeta features.

bank-microservices
├── services
   ├── client
   ├── src
   ├── app
   ├── _components
   └── ...
   ├── loans
   ├── _components
   ├── page.tsx
   └── ...
   └── ...
   ├── layout.tsx
   ├── page.tsx
   └── ...
   ├── features
   ├── loans
   ├── core
   ├── interactors
   └── ...
   └── repositories
       └── ...
   └── data-sources
       └── ...
   └── ...
   ├── shared
   └── ...
   └── ...
   └── ...
   └── ...
└── ...

Veamos en que consisten estas dos carpetas a continuación.

App

El uso del router App nos permite hacer uso de los React Server Components y así optimizar el rendimiento de la aplicación.

En el caso de necesitar interactividad en el lado del cliente tan solo es necesario colocar la directiva 'use client' al principio del archivo.

Además es conveniente advertir del uso de convenciones para los nombres de los archivos. Así, page y layout se repiten sucesivamente en cada ruta.

Un layout se comparte en múltiples páginas, y cada page es única a cada ruta.

De esta manera, al navegar a la ruta /loans nos aparecerán, entre otras, la tarjeta del formulario que vimos más arriba para la creación de un préstamo:

services/client/src/app/loans/page.tsx
export default function Page(): JSX.Element {
  return (
    <div className={styles.container}>
      <CardList>
        <CreateLoanCard />
        <UpdateLoanCard />
        <DeleteLoanCard />
        <GetAllLoansCard />
        <GetLoanCard />
        <RequestLoanDefaultCard />
        <SaveLoanOnChainCard />
      </CardList>
    </div>
  );
}

Features

En esta carpeta se implementan las lógicas de negocio y las fuentes de datos para cada sección (accounts, customers, loans y requests).

La estructura de archivos es similar a la que se utiliza en las APIs de los servidores:

  • core: en esta carpeta, como ya dijimos, encontraremos la lógica de negocio de cada sección:

    • interactores: los interactores son el resultado de la implementación de todos los casos de uso de cada sección. Están diseñados para aceptar como argumento un repositorio, y devolver una función con este repositorio ya inyectado.

      Por ejemplo, abajo podemos ver el interactor createLoan:

      services/client/src/features/loans/core/interactors/create-loan.interactor.ts
      const createLoan =
        (repository: LoanRepository) =>
        async (loan: Loan): Promise<InteractorResponse> => {
          try {
            const response = await repository.create(loan);
            return response;
          } catch (error) {
            if (error instanceof Error) {
              const { message } = error;
              return { success: false, error: true, message, data: null };
            } else {
              return { success: false, error: true, message: errorMessage, data: null };
            }
          }
        };
       
      export default createLoan;

      El interactor llama al método create del repositorio y devuelve una respuesta del tipo InteractorResponse. Si se produce algún error, se genera un objeto del mismo tipo con mensajes de error.

    • repositorios: en el repositorio definimos los métodos necesarios para ocuparse de todos los casos de uso. Abajo vemos parte del repositorio de los préstamos:

      services/client/src/features/loans/core/repositories/loan.repository.ts
      interface LoanRepository {
        /**
        * Create a loan.
        * @param loan `Loan` entity object.
        * @returns a Promise that resolves to an `InteractorResponse` object.
        */
        create: (loan: Loan) => Promise<InteractorResponse>;
       
        /**
        * Delete a loan by its identifier.
        * @param identifier loan identifier.
        * @returns a Promise that resolves to an `InteractorResponse` object.
        */
        deleteByIdentifier: (identifier: number) => Promise<InteractorResponse>;
       
        // ...
      }
       
      export default LoanRepository;
  • data sources: todos los casos de uso que se describen en los repositorios se implementan en los data sources, o fuentes de datos. A continuación vemos una parte del que corresponde a los préstamos:

    services/client/src/features/loans/data-sources/loan.data-source.ts
    class LoansDataSource implements LoanRepository {
      public async create(loan: Loan): Promise<InteractorResponse> {
        const response = await this.fetchWrapper(createPath, createMethod, { loan });
        return response.json();
      }
     
      public async deleteByIdentifier(identifier: number): Promise<InteractorResponse> {
        const response = await this.fetchWrapper(join(deletePath, identifier.toString()), deleteByIdentifierMethod);
        return response.json();
      }
     
      // ...
     
      /**
      * Fetch wrapper.
      * @param path the path of the URL you want to conform.
      * @param method the request method.
      * @param body the body that you want to add to your request.
      * @returns a Promise that resolves to a `Response` object.
      */
      private async fetchWrapper(path: string, method: string, body?: any, isModel?: boolean): Promise<Response> {
        const validatedMethod = validateRequestMethod(method);
        if (validatedMethod === null) {
          const message = 'Invalid request method';
          throw new Error(message);
        }
        const url = new URL(path, isModel ? loanModelBase : loanBase).href;
        const bodyString = body ? JSON.stringify(body) : undefined;
        return fetchWrapper(url, validatedMethod, bodyString, isModel ? 'cors' : undefined);
      }
    }
     
    export default LoansDataSource;

    En la línea 21 podemos ver un método privado que se utiliza para una promesa que resuelve a un objeto de tipo Response.

    Se trata de una función que utilizan todos los casos de uso y que envuelve al método fetch para empezar el proceso de obtención de un recurso de un servidor.

Por último, podemos ver en la siguiente imagen la sección de préstamos y todos sus casos de uso:

Conclusión

En esta publicación he intentado introducir mi propuesta de una sencilla aplicación bancaria de arquitectura basada en microservicios que implemente los casos de uso principales para cada servicio.

Además, se ha dejado constancia de un ejemplo en el que coexisten tecnología de inteligencia artificial y de cadena de bloques, mediante el despliegue de:

  • un pipeline que incluye una red neuronal de clasificación binaria implementada con PyTorch.

  • unos contratos inteligentes desarrollados en lenguaje Solidity.

  • unos subgrafos utilizando TheGraph que en la práctica actúan como una capa de indexación entre los contratos y la interfaz de usuario.


Artículos relacionados

Aprendizaje por refuerzo

Formación de un agente DQN en el juego de Conecta Cuatro

Ejercicio para obtener una estrategia, plan o política de acción (policy) que sea capaz de...

zk-SNARK

Cómo desarrollar una DApp de conocimiento cero

Esta entrada ofrece una introducción a cómo desarrollar una aplicación capaz de generar y validar...

DeFi

Guía de principio a fin para crear una DApp específica para un token ERC-20

Aplicación descentralizada para operar con un token ERC-20 acuñable

Trazabilidad

Presentando Olive Oil Trust: subgrafo

Subgrafo para consultar datos de Olive Oil Trust de una manera eficiente usando TheGraph.

Regresión

Prediciendo precios de casas de Ames: flujo de trabajo secuencial

Regresión de precios en el conjunto de datos Ames housing prices

DeFi

Cómo crear una aplicación de analítcas de DEXs

Introducción a la obtención y representación de datos de DEXs utilizando TheGraph y React.js


¿Preparado para #buidl?

¿Está interesado en Web3 o en las sinergias entre la tecnología blockchain, la inteligencia artificial y el conocimiento cero?. Entonces, no dude en contactarme por e-mail o en mi perfil de LinkedIn. También me puede encontrar en GitHub.