Layers And Rulesets

Layers tell StructArmed where classes belong. Rulesets tell StructArmed which layer dependencies are allowed.

Contents

  1. Path-Based Layers
    1. Multiple Layer Paths
    2. Excluding A Nested Path From A Layer
  2. Namespace-Based Layers
    1. Multiple Layer Patterns
    2. Excluding From Layer Patterns
  3. Declarative Rulesets
    1. Inheriting Allowed Layers
    2. Skipping Class-Level Violations
    3. Excluding Paths From Ruleset Checks Only

Path-Based Layers

Each class is assigned a layer based on which registered layer() path its file falls under. No class attributes are needed.

// Files under src/Domain/ resolve to layer 'Domain'.
->layer('Domain', 'src/Domain/')

// Files under src/Application/ resolve to layer 'Application'.
->layer('Application', 'src/Application/')

// Files under src/Infrastructure/ resolve to layer 'Infrastructure'.
->layer('Infrastructure', 'src/Infrastructure/')

Multiple Layer Paths

You can assign a layer using multiple paths.

return Architecture::define()
    ->layer('Service', [
        'src/Service/',
        'src/Application/Service/',
    ]);

Excluding A Nested Path From A Layer

A path layer owns everything under its path. When a nested directory is registered as its own layer, its classes belong to both layers: rules targeting the parent layer still apply to them, and the ruleset treats parent-to-child dependencies as same-layer.

An optional third argument carves the nested directory out of the parent layer:

// Files under src/Logger/Factory/ resolve to 'Factory' only, not 'Logger'.
->layer('Logger',  'src/Logger/', 'src/Logger/Factory/')
->layer('Factory', 'src/Logger/Factory/')

Use an array of exclude paths when several nested directories should be omitted from the same parent layer.

->layer('Logger', 'src/Logger/', [
    'src/Logger/Factory/',
    'src/Logger/Formatter/',
])

With the configuration above, files such as src/Logger/FileLogger.php still resolve to Logger, while files under src/Logger/Factory/ and src/Logger/Formatter/ do not.

Excluding only removes the files from that one layer: they still resolve to any other layer() whose path covers them (including Source) and to any matching layerPattern(). When nothing else matches, they resolve to no layer.

Namespace-Based Layers

When your architecture is expressed through namespace conventions rather than directory structure, use layerPattern() to resolve layers by matching the fully-qualified class name against a regex.

return Architecture::define()
    ->layerPattern('API',    '/^App\\\\API\\\\.*$/')
    ->layerPattern('HTTP',   '/^App\\\\HTTP\\\\.*$/')
    ->layerPattern('Router', '/^App\\\\Router\\\\.*$/');

Multiple Layer Patterns

You can assign a layer using multiple regexes.

return Architecture::define()
    ->layerPattern('Service', [
        '/^App\\\\Service\\\\.*$/',
        '/^App\\\\Application\\\\.*Service$/',
    ]);

Excluding From Layer Patterns

An optional third argument excludes classes whose FQN matches one or more regexes, even when the layer pattern matches.

Use a single exclude regex when one class or namespace branch should not belong to a broader layer. In this example, App\HTTP\URI matches the broad HTTP pattern, but it is excluded from HTTP and then registered as its own URI layer.

// HTTP layer: includes everything under App\HTTP\, except App\HTTP\URI.
->layerPattern('HTTP', '/^App\\\\HTTP\\\\.*$/', '/^App\\\\HTTP\\\\URI$/')
->layerPattern('URI',  '/^App\\\\HTTP\\\\URI$/')

Use an array of exclude regexes when several class-name patterns should be omitted from the same broad layer.

->layerPattern('HTTP', '/^App\\\\HTTP\\\\.*$/', [
    '/Exception$/',
    '/^App\\\\HTTP\\\\URI$/',
])

With the configuration above, classes such as App\HTTP\Request still resolve to HTTP, while App\HTTP\RequestException and App\HTTP\URI do not.

Declarative Rulesets

Once layers are defined, declare which layers each layer is allowed to depend on. Any dependency that resolves to a layer outside the allowed list is a violation.

return Architecture::define()
    ->layerPattern('API',      '/^App\\\\API\\\\.*$/')
    ->layerPattern('HTTP',     '/^App\\\\HTTP\\\\.*$/')
    ->layerPattern('Database', '/^App\\\\Database\\\\.*$/')
    ->ruleset([
        'API'      => ['HTTP'],     // API may only depend on HTTP.
        'HTTP'     => ['Database'], // HTTP may only depend on Database.
        'Database' => [],           // Database may not depend on any layer.
    ]);

Layers absent from the ruleset keys are not checked. Dependencies on external, non-registered classes are always allowed.

Same-layer dependencies are always allowed regardless of the ruleset.

Inheriting Allowed Layers

Use the + prefix to merge a layer’s allowed dependencies into another layer’s allowed list.

->ruleset([
    'API'        => ['Format'],
    'Controller' => ['Validation'],
    'RESTful'    => ['+API', '+Controller'],
])

Each +LayerName entry expands to that layer itself plus all layers it is allowed to depend on.

-'RESTful' => ['API', 'Format', 'Controller', 'Validation'],
+'RESTful' => ['+API', '+Controller'],

When the allowed layers of API or Controller change, RESTful picks them up automatically. Unknown + references expand to nothing silently.

Skipping Class-Level Violations

When a specific class-to-class dependency is a known exception, suppress it without disabling the whole layer rule.

->skipClassViolation('App\\HTTP\\ResponseTrait', [
    'App\\Pager\\PagerInterface',
])
->skipClassViolation('App\\Log\\ChromeLoggerHandler', 'App\\HTTP\\ResponseInterface')

The first argument is the fully-qualified violating class name. The second argument is one or more dependency FQNs to ignore for that class.

Excluding Paths From Ruleset Checks Only

Test files often cross layer boundaries by design. Use skipPathsForRuleset() to exclude paths from ruleset evaluation while still scanning them for all other rules, such as PSR-4 namespace checks.

return Architecture::define()
    ->withPresets(Preset::PSR4())
    ->layerPattern('HTTP',     '/^App\\\\HTTP\\\\.*$/')
    ->layerPattern('Database', '/^App\\\\Database\\\\.*$/')
    ->ruleset(['HTTP' => [], 'Database' => ['HTTP']])
    ->skipPathsForRuleset(['*tests*', '*fixtures*']);

This is different from skipPaths() and skipPath(), which exclude files from all analysis.