Skip to content

Latest commit

 

History

History
96 lines (76 loc) · 3.21 KB

modularity.md

File metadata and controls

96 lines (76 loc) · 3.21 KB

Modularity

You compose an application from modules. A module is a class implementing the Module<T> interface:

use Thesis\Dic;
use Thesis\Dic\Module;
use Thesis\Dic\Ref;
use function Typhoon\Type\objectT;

/**
 * @implements Module<Ref<Cache>>
 */
final readonly class CacheModule implements Module
{
    public function configure(Dic $dic): mixed
    {
        return $dic
            ->object(RedisCache::class)
            ->bind(objectT(Cache::class));
    }
}

The root module is the one you hand to Dic::run() or Dic::build().

import(): isolated bindings

import() runs a module in a fresh Dic of its own, then hands you back whatever it returns:

/**
 * @implements Module<Ref<Application>>
 */
final readonly class AppModule implements Module
{
    public function configure(Dic $dic): mixed
    {
        $cache = $dic->import(new CacheModule()); // Ref<Cache>

        return $dic
            ->object(ProductRepository::class)
            ->arg('cache', $cache);
    }
}

The submodule's bindings live in that fresh Dic, so they neither leak out nor see yours. An imported module is a black box: you wire it in only through the Refs it exports and the Refs you pass in — never through a shared type binding. That is why AppModule injects the exported $cache explicitly instead of autowiring Cache: CacheModule's binding is invisible here.

Autoconfiguration is scoped the same way: an onObject() / onFunction() listener registered inside a module visits only that module's services, and an imported module's listeners never touch yours.

Isolation is only about configuration — the binding table and the Dic surface. The underlying container is still shared, so every service across every module is built once, in one container.

For third-party modules, isolation is not just useful — it is the only safe option. You do not control a vendor's bindings, and isolation guarantees their autowiring choices never collide with yours, nor accidentally satisfy one of your parameters.

apply(): shared scope

apply() calls a function on the same $dic, so its bindings and autoconfiguration listeners stay visible:

final readonly class AppModule implements Module
{
    public function configure(Dic $dic): mixed
    {
        $dic->apply($this->registerCache(...));

        return $dic->object(ProductRepository::class); // autowires Cache from registerCache()
    }

    private function registerCache(Dic $dic): void
    {
        $dic
            ->object(RedisCache::class)
            ->bind(objectT(Cache::class));
    }
}

apply() is a lightweight way to split a large configure() into smaller functions without changing the scope. A direct call $this->registerCache($dic) does the same thing; apply() only names the intent.

Because the scope is shared, a type bound in one function is autowirable in any of the others, and an onObject() / onFunction() convention registered via apply() applies to every service on that $dic.

The flip side: a binding added in one applied function can silently change how another resolves a type. Use apply() when the functions are tightly coupled parts of the same module; use import() when the boundary matters.