vijoni/organized-modules

Organize PHP modules into isolated business units.

1.0.2 2022-04-27 08:19 UTC

This package is auto-updated.

Last update: 2024-04-27 11:10:54 UTC


README

Organize PHP modules into isolated business units.

Imagine Service Oriented Architecture (SOA) in Monolith code base.
This library handles creation and dependency injection of modules nested inside a business service or in a bounded context (term from DDD). This concept could be also known as Majestic Monolith.

Let's take as an example room renting application. In your business you could identify following units and related with them use cases:

  • booking; room reservation, confirmation, cancellation,
  • sales; payment, refund, discount
  • operations; login management, user management, room management
  • marketing; notification, campaign

In either SOA, Microservices, Monolith approach you need to identify your business cases. The difference is that in case of SOA and Microservices you need to take into account the complexity of a distributed system. With this library you can isolate and group business requirements inside single repository.

Structure

Check acceptance tests for details

The image presents Sales unit with Order and Payment modules.

Actions/Controllers

You have to define your Action/Controller classes in a directory inside the Module. This is important, as it is assumed that ModuleFacade, ModuleFactory, ModuleConfig, DependencyProvider exist directly in the Module's directory, one-level above the Action/Controller class. The name of the directory is not important.
Dependency injection logic discovers the classes following this directory structure.
Gain access to $this->moduleFactory().

UnitFacade

Every Unit should contain a UnitFacade class. It should access ONLY its own Unit's ModuleFacades. UnitFacade exposes the methods/functionality you need in other Units.
For example in Booking Unit you may need to access a price calculation for the renting a room.

ModuleFacade

Every Module, should contain a ModuleFacade. It should access ONLY its own Module's ModuleFactory. ModuleFacade exposes methods/functionality you need in other Modules inside its own Unit. For example Order Module may need to access Payment Module's functionality. Gain access to $this->moduleFactory().

ModuleFactory

Here you define object creation methods and handle their dependencies. Inside ModuleFactory you should manually instantiate ONLY classes defined in its own Module. Dependencies coming from outside the Module should be defined in ModuleDependencyProvider. ModuleFactory should not access its own ModuleFacade. Every factory also has access to application configuration object.
Factories can also be runtime variant specific, more on that later. Gain access to $this->dependencyProvider(), $this->config().

DependencyProvider

It exposes external dependencies, like UnitFacades, ModuleFacades and other objects like database connection, api client, logger etc.

ModuleConfig

Here you define methods returning Module specific configuration values or expose application configuration values. Every ModuleConfig has access to global AppConfig object.
ModuleConfigs can also be runtime variant specific, more on that later.

Usage

Initialize Action/Controller

There are different ways to load the dependencies.

Through Interface

Action/Controller class has to implement ModuleActionInterface, use the ModuleActionDependency trait and call DependencyProvider::fillActionDependencies($this) on itself.

class CreateOrderAction implements ModuleActionInterface
{
  use ModuleActionDependency;

  public function __construct()
  {
    DependencyProvider::getInstance()->fillActionDependencies($this);
  }
}

Through Inheritance

Action/Controller class has to extend ModuleAction class.

class CreateOrderAction extends ModuleAction
{
    public function __construct()
    {
        parent::__construct();
    }
}

Custom integration

Action/Controller class has to implement ModuleActionInterface and use the ModuleActionDependency trait.
You can overwrite your framework's controller resolution class or register your own autoloader and use the ModuleActionInterface to identify classes upon which DependencyProvider::fillActionDependencies() should be called.

Expose configuration to ModuleFactories

Call DependencyProvider::setConfig([]) on the application bootstrap.

// app_config.php
<?php

return [
  'database' => [
    'host' => '127.0.0.1',
    'user' => 'dbuser',
  ],
  'stripe' => [
    'api_key' => 'xxx-xxx-xxx',
  ]
];
---------------------------------------

// index.php
$appConfig = AppConfig::fromFile(__DIR__ . '/app_config.php');
$dependencyProvider = DependencyProvider::getInstance();
$dependencyProvider->setConfig($appConfig);

Action/Controller dependencies

Every Action/Controller has access to its Module's ModuleFactory.

class CreateOrderAction extends ModuleAction
{
  public function __invoke(): void
  {
    $orderService = $this->moduleFactory()->newCreateOrderService();
  }
}

ModuleFactory dependencies

Every ModuleFactory has access to its Module's ModuleConfig and DependencyProvider.
Factory methods can create new object instance with every call or cache the instance for future usages. You can use your own naming convention. Here methods are prefixed with new or share. Shared instances will always be the same. Anonymous function is called only once per ModuleFactory instance.

/**
 * @method ModuleDependencyProvider dependencyProvider()
 * @method ModuleConfig config()
 */
class ModuleFactory extends BaseModuleFactory
{
  public function newCreateOrderService(): CreateOrderService
  {
    return new CreateOrderService($this->shareOrderRepository(), $this->shareOrderValidator());
  }
  
  protected function shareOrderRepository(): OrderRepository
  {
    /** @var OrderRepository */
    return $this->share(
      OrderRepository::class,
      fn () => new OrderRepository($this->shareOrderReadGateway(), $this->shareOrderWriteGateway())
    );
  }

  protected function shareOrderValidator(): OrderValidator
  {
    $paymentFacade = $this->dependencyProvider()->sharePaymentFacade();

    /** @var OrderValidator */
    return $this->share(
      OrderValidator::class,
      fn () => new OrderValidator($paymentFacade)
    );
  }
}

Variant specific ModuleFactory

By default, classes named ModuleFactory are looked for, but you may need different logic for every country in which you run your application. For example You serve DE, GB application and for GB you need different behaviour than the default one or than the one for DE.
For this you could create separate Factory classes and overwrite the default method. To make this to work you need to set a variant on the DependencyProvider::setVariant in your application's bootstrap. If variant specific ModuleFactory does not exist, the default one will be used.

// index.php
$dependencyProvider = DependencyProvider::getInstance();
// for example, you could grab the value from environment variable defined
// in your http server domain specific configuration
$dependencyProvider->setVariant(env(APP_STORE)); 
---------------------------------------

// ModuleFactory.php
/**
 * @method ModuleDependencyProvider dependencyProvider()
 * @method ModuleConfig config()
 */
class ModuleFactory extends BaseModuleFactory
{
  public function newCreateOrderService(): CreateOrderService
  {
    return new CreateOrderService();
  }
}
---------------------------------------

// ModuleFactoryGB.php
/**
 * @method ModuleDependencyProvider dependencyProvider()
 * @method ModuleConfig config()
 */
class ModuleFactoryGB extends ModuleFactory
{
  public function newCreateOrderService(): CreateOrderService
  {
    return new CreateOrderServiceAllForFree();
  }
}

ModuleConfig dependencies

Every ModuleConfig has access to AppConfig, set using DependencyProvider::setConfig().

class ModuleConfig extends BaseModuleConfig
{
  public function readStripeApiKey(): string
  {
    return $this->config()->getString('stripe.api_key');
  }
}

Variant specific ModuleConfig

By default, classes named ModuleConfig are looked for, but you may need different values for every country in which you run your application. For example You serve DE, GB application and for GB you need different setting than default one or than the one for DE.
For this you could create separate Config classes and overwrite the default values. To make this to work you need to set a variant on the DependencyProvider::setVariant in your application's bootstrap. If variant specific ModuleConfig does not exist, the default one will be used.
Alternative could also be to load a country specific configuration file in the first place.

// index.php
$dependencyProvider = DependencyProvider::getInstance();
// for example, you could grab the value from environment variable defined
// in your http server domain specific configuration
$dependencyProvider->setVariant(env(APP_STORE)); 
---------------------------------------

class ModuleConfig extends BaseModuleConfig
{
  public function isPurchaseEnabled(): bool
  {
    return true;
  }
}
---------------------------------------

class ModuleConfigGB extends ModuleConfig
{
  public function isPurchaseEnabled(): bool
  {
    return false;
  }
}

ModuleDependencyProvider dependencies

Objects like database connection or api clients are defined outside the Module or maybe in different Units. ModuleDependencyProvider can expose any ModuleFacade, UnitFacade or other objects registered in DepedencyProvider. This creates a wall between Modules, Units and "outside world".

// index.php
$dp = DependencyProvider::getInstance();
$dp->register(
  DatabaseConnection::class
  fn => new DatabaseConnection();
);
---------------------------------------

class ModuleDependencyProvider extends BaseModuleDependencyProvider
{
  public function sharePaymentFacade(): PaymentModuleFacade
  {
    /** @var PaymentModuleFacade */
    return $this->dependencyProvider()->shareModuleFacade(PaymentModuleFacade::class);
  }

  public function shareDatabaseConnection(): DatabaseConnection
  {
    /** @var DatabaseConnection */
    return $this->dependencyProvider()->share(DatabaseConnection::class);
  }
}