Modularity

architecture

Modularity

Definition

Modularity is the design principle of decomposing a software system into discrete, independent, and interchangeable components -- called modules -- each encapsulating a coherent set of responsibilities behind well-defined interfaces. A modular system allows individual components to be developed, tested, deployed, and evolved independently while maintaining clear contracts for inter-component communication. In the context of the Prismatic Platform, modularity manifests as 115 umbrella applications organized by domain, connected through behaviours, protocols, and the adapter pattern.

Overview

Modularity is not merely a structural convenience -- it is a foundational architectural decision that determines a system's capacity for growth, maintainability, and resilience. Systems that lack modularity suffer from tight coupling, where changes in one area cascade unpredictably into others. They resist testing because components cannot be isolated. They resist evolution because refactoring any single piece requires understanding the entire monolith.

The Prismatic Platform embraces modularity at every level of its architecture. At the macro level, the Elixir umbrella application structure divides the platform into 115 distinct applications, each with its own supervision tree, test suite, and dependency manifest. At the micro level, individual modules use behaviours and protocols to define contracts that enable multiple implementations to coexist transparently. At the operational level, the AIAD standard organizes 530+ agents into modular hierarchies where each agent has a single, well-defined responsibility.

The benefits compound over time. When the platform needed to add KuzuDB graph storage alongside existing PostgreSQL and ETS backends, modularity through the adapter pattern meant the new backend could be added without modifying a single line of existing storage code. When the OSINT toolbox grew from 20 to 120 tools, each tool was an independent module conforming to a shared behaviour, making the expansion trivial to manage.

Technical Details

Modularity Dimensions

Modularity operates across several orthogonal dimensions, each contributing to the overall decomposability of a system:

Structural Modularity concerns the physical organization of code into separate compilation units, packages, or applications. In Elixir, this means separate mix.exs files, distinct lib/ directories, and isolated test/ folders for each umbrella application.

Behavioral Modularity concerns the definition of contracts that multiple implementations can satisfy. Elixir's @behaviour and @callback mechanisms provide compile-time enforcement of these contracts, while protocols offer runtime polymorphism based on data types.

Deployment Modularity concerns the ability to build, release, and scale individual components independently. OTP releases can include subsets of umbrella applications, enabling targeted deployment of specific capabilities.

Data Modularity concerns the isolation of data ownership. Each module owns its data structures and exposes them through explicit APIs rather than sharing internal representations. This prevents the "shared database" anti-pattern that plagues monolithic architectures.

Key Metrics

The quality of modular decomposition can be measured through several metrics:

MetricDefinitionPrismatic Target
CohesionHow related are elements within a moduleHigh (single domain per app)
CouplingHow dependent are modules on each otherLow (behaviour-mediated)
Fan-inNumber of modules depending on this moduleControlled via facade pattern
Fan-outNumber of modules this module depends onMinimized via DI
InstabilityFan-out / (Fan-in + Fan-out)Balanced per dependency direction
AbstractnessRatio of abstract to concrete elementsHigh for core, low for adapters

Information Hiding

The principle of information hiding, introduced by David Parnas in 1972, is the theoretical foundation of modularity. Each module conceals its internal design decisions behind a stable interface. When internal decisions change -- algorithm optimizations, data structure modifications, implementation language switches -- the rest of the system remains unaffected.

In Elixir, information hiding is achieved through:

  • Module-level encapsulation (private functions via defp)
  • Opaque types that prevent callers from depending on internal structure
  • Facade modules that expose curated public APIs for entire applications

Implementation in Prismatic Platform

Umbrella Application Structure

The Prismatic Platform's primary modularity mechanism is the Elixir umbrella application structure. Each of the 115 applications represents a bounded domain with explicit dependencies:

defmodule PrismaticStorageCore.MixProject do
  use Mix.Project

  @spec project() :: keyword()
  def project do
    [
      app: :prismatic_storage_core,
      version: "0.1.0",
      build_path: "../../_build",
      deps_path: "../../deps",
      lockfile: "../../mix.lock",
      elixir: "~> 1.19",
      deps: deps(),
      dialyzer: [plt_add_apps: [:ex_unit]],
      test_coverage: [threshold: 80]
    ]
  end

  @spec deps() :: [tuple()]
  defp deps do
    [
      # Only explicit, declared dependencies
      {:prismatic_storage_core, in_umbrella: true},
      {:telemetry, "~> 1.2"}
    ]
  end
end

Behaviour-Based Modularity

The platform uses behaviours extensively to define module contracts. Storage backends, agent implementations, and OSINT adapters all follow this pattern:

defmodule PrismaticStorageCore.Adapter do
  @moduledoc """
  Behaviour contract for all storage adapters in the Prismatic Platform.
  Implementations include ETS, Ecto (PostgreSQL), Meilisearch, and KuzuDB.
  """

  @type key :: term()
  @type value :: term()
  @type opts :: keyword()
  @type error :: {:error, atom() | String.t()}

  @callback init(opts()) :: {:ok, state :: term()} | error()
  @callback get(key(), opts()) :: {:ok, value()} | {:error, :not_found} | error()
  @callback put(key(), value(), opts()) :: :ok | error()
  @callback delete(key(), opts()) :: :ok | error()
  @callback list(opts()) :: {:ok, [value()]} | error()

  @doc "Returns adapter metadata for introspection."
  @callback adapter_info() :: %{
    name: atom(),
    version: String.t(),
    capabilities: [atom()]
  }
end

Adapter Swapping at Runtime

Because modules communicate through behaviours rather than concrete implementations, the platform can swap adapters without code changes:

defmodule PrismaticStorage.Registry do
  @moduledoc """
  Selects the appropriate storage adapter based on configuration.
  Demonstrates runtime modularity through behaviour-based dispatch.
  """

  @spec adapter() :: module()
  def adapter do
    Application.get_env(:prismatic_storage, :adapter, PrismaticStorage.ETS)
  end

  @spec get(term(), keyword()) :: {:ok, term()} | {:error, atom()}
  def get(key, opts \\ []) do
    adapter().get(key, opts)
  end

  @spec put(term(), term(), keyword()) :: :ok | {:error, atom()}
  def put(key, value, opts \\ []) do
    adapter().put(key, value, opts)
  end
end

AIAD Agent Modularity

The AIAD standard extends modularity to agent definitions. Each of the 530+ agents is a self-contained specification with explicit dependencies, capabilities, and interfaces:

defmodule PrismaticAgents.Runtime.Agent do
  @moduledoc """
  Runtime agent behaviour defining the contract for all AIAD agents.
  Each agent is an independent module with lifecycle management.
  """

  @type agent_id :: String.t()
  @type agent_state :: map()
  @type command :: atom()
  @type result :: {:ok, term()} | {:error, term()}

  @callback init(config :: map()) :: {:ok, agent_state()} | {:error, term()}
  @callback handle_command(command(), args :: map(), agent_state()) :: {result(), agent_state()}
  @callback capabilities() :: [atom()]
  @callback dependencies() :: [agent_id()]
  @callback tier() :: 1..5
end

Dependency Graph Enforcement

The platform enforces modularity through automated dependency analysis, preventing circular dependencies and ensuring layered architecture compliance:

defmodule PrismaticSupervisor.DependencyResolver do
  @moduledoc """
  Validates and resolves inter-application dependencies.
  Detects circular dependencies and enforces layered architecture.
  """

  @type app :: atom()
  @type dep_graph :: %{app() => [app()]}

  @spec resolve(dep_graph()) :: {:ok, [app()]} | {:error, {:circular, [app()]}}
  def resolve(graph) do
    case topological_sort(graph) do
      {:ok, sorted} -> {:ok, sorted}
      {:error, cycle} -> {:error, {:circular, cycle}}
    end
  end

  @spec validate_layers([app()], %{app() => atom()}) :: :ok | {:error, [{app(), app()}]}
  def validate_layers(apps, layer_map) do
    violations =
      for app <- apps,
          dep <- Map.get(app, :deps, []),
          layer_rank(layer_map[app]) < layer_rank(layer_map[dep]),
          do: {app, dep}

    case violations do
      [] -> :ok
      list -> {:error, list}
    end
  end

  @spec layer_rank(atom()) :: non_neg_integer()
  defp layer_rank(:core), do: 0
  defp layer_rank(:storage), do: 1
  defp layer_rank(:domain), do: 2
  defp layer_rank(:web), do: 3
  defp layer_rank(_), do: 4

  @spec topological_sort(dep_graph()) :: {:ok, [app()]} | {:error, [app()]}
  defp topological_sort(graph) do
    # Kahn's algorithm for topological sorting
    in_degrees = compute_in_degrees(graph)
    queue = for {node, 0} <- in_degrees, do: node

    do_sort(queue, graph, in_degrees, [])
  end

  @spec compute_in_degrees(dep_graph()) :: %{app() => non_neg_integer()}
  defp compute_in_degrees(graph) do
    all_nodes = Map.keys(graph) ++ List.flatten(Map.values(graph))

    base = Map.new(Enum.uniq(all_nodes), fn node -> {node, 0} end)

    Enum.reduce(graph, base, fn {_node, deps}, acc ->
      Enum.reduce(deps, acc, fn dep, inner_acc ->
        Map.update(inner_acc, dep, 1, &(&1 + 1))
      end)
    end)
  end

  @spec do_sort([app()], dep_graph(), map(), [app()]) ::
          {:ok, [app()]} | {:error, [app()]}
  defp do_sort([], _graph, in_degrees, sorted) do
    if Enum.all?(in_degrees, fn {_k, v} -> v == 0 end) do
      {:ok, Enum.reverse(sorted)}
    else
      cycle = for {node, deg} <- in_degrees, deg > 0, do: node
      {:error, cycle}
    end
  end

  defp do_sort([node | rest], graph, in_degrees, sorted) do
    deps = Map.get(graph, node, [])

    updated_degrees =
      Enum.reduce(deps, in_degrees, fn dep, acc ->
        Map.update!(acc, dep, &(&1 - 1))
      end)

    new_queue = for dep <- deps, Map.get(updated_degrees, dep) == 0, do: dep
    updated_degrees = Map.put(updated_degrees, node, 0)

    do_sort(rest ++ new_queue, graph, updated_degrees, [node | sorted])
  end
end

Comparison with Alternatives

Modularity vs. Monolithic Architecture

AspectModular (Prismatic)Monolithic
DeploymentIndependent per appAll-or-nothing
TestingIsolated, parallelGlobal, sequential
Team scalingTeams own modulesEveryone touches everything
Compile timeIncrementalFull rebuild
Dependency controlExplicit, enforcedImplicit, often circular
Risk of changeLocalizedSystem-wide

Modularity vs. Microservices

Microservices take modularity to the extreme by running each module as an independent network service. The Prismatic Platform uses a middle ground -- umbrella applications that are modular at the code level but deploy as a single BEAM node. This avoids the operational complexity of microservices (service discovery, network latency, distributed transactions) while retaining the development benefits of modularity.

AspectUmbrella ModularityMicroservices
CommunicationFunction calls (microseconds)Network calls (milliseconds)
ConsistencyLocal transactionsDistributed sagas
DeploymentSingle releaseIndependent services
Operational overheadLow (single node)High (orchestration, networking)
Fault isolationOTP supervision treesProcess isolation
Data sharingShared BEAM VM memorySerialized over network

Modularity vs. Plugin Architecture

Plugin architectures provide extensibility through dynamic loading of components. While powerful for third-party extensibility, they sacrifice compile-time guarantees. Prismatic's behaviour-based modularity provides both extensibility and compile-time contract verification -- a stronger guarantee than most plugin systems offer.

Best Practices

1. Define Boundaries Before Writing Code

Start by identifying bounded contexts in your domain. Each context becomes a separate umbrella application with its own supervision tree and public API. This prevents the common failure mode of splitting modules after a monolith has already formed.

2. Depend on Abstractions, Not Implementations

All inter-module communication should flow through behaviours or protocols, never through direct calls to internal functions. This is the Dependency Inversion Principle applied to Elixir -- high-level modules should not depend on low-level modules; both should depend on abstractions.

3. Enforce One-Way Dependencies

Dependencies should flow in a single direction: from high-level application modules down to low-level infrastructure modules. The PrismaticSupervisor.DependencyResolver enforces this at build time by detecting circular dependencies and layer violations.

4. Keep Module Public APIs Minimal

Each module should expose the smallest possible public interface. Use defp for internal functions, opaque types for internal data structures, and facade modules to present a curated API. The smaller the interface, the easier it is to maintain backward compatibility.

Group related functions, types, and tests within the same module or application. Avoid the "layers by type" anti-pattern (all controllers in one directory, all models in another). Instead, organize by feature or domain -- the approach used throughout Prismatic's apps/ directory.

6. Design for Independent Testability

Every module should be testable in complete isolation. If testing a module requires starting the entire application, it is too tightly coupled. Use dependency injection to replace collaborators with test doubles during testing.

7. Version Module Interfaces

When a module's public API changes, treat it as a contract change. Use semantic versioning for umbrella applications and maintain changelog documentation. Breaking changes should be rare and well-communicated.

Common Pitfalls

1. Over-Modularization

Creating too many tiny modules with trivial responsibilities leads to "module soup" -- a system where understanding any feature requires tracing through dozens of files. Each module should represent a meaningful abstraction, not just a single function. The Prismatic Platform avoids this by anchoring modularity at the bounded context level.

2. Shared Mutable State Across Modules

Modules that share ETS tables, GenServer state, or database tables directly break encapsulation. All cross-module data access should go through the owning module's public API. This is enforced in Prismatic through the storage adapter pattern.

3. Cyclic Dependencies

When module A depends on module B and module B depends on module A, modularity is compromised. Cycles indicate that the module boundaries are drawn incorrectly. The platform's mix supervisor deps --cycles command detects these at build time.

4. Leaky Abstractions

When a module's internal implementation details leak through its public API -- for example, returning raw Ecto structs instead of domain-specific types -- callers become coupled to those internal details. Changes to the internal implementation then break callers, defeating the purpose of modularity.

5. God Modules

A single module that accumulates responsibilities over time becomes a de facto monolith within the modular structure. Watch for modules with more than 500 lines of code, more than 20 public functions, or names ending in "Manager", "Handler", or "Utils" -- all signs of insufficient decomposition.

6. Ignoring Compile-Time Dependencies

In Elixir, compile-time dependencies (macros, use, import) create stronger coupling than runtime dependencies. A module that uses another must recompile when the used module changes. Prefer runtime function calls over compile-time dependencies where possible.

Use Cases

1. Storage Backend Evolution

When the platform needed to add KuzuDB graph storage for entity relationships, the modular adapter pattern meant creating a new PrismaticStorageKuzu application that implemented the existing PrismaticStorageCore.Adapter behaviour. Zero changes were required in any consuming module.

2. OSINT Tool Expansion

The OSINT toolbox grew from 20 to 120 adapters across 7 categories. Each adapter is an independent module implementing a shared behaviour. New tools are added by creating a single module file -- no coordination with existing tools required.

3. Multi-Team Development

With 115 umbrella applications, multiple developers can work on different parts of the platform simultaneously without merge conflicts. Team A can refactor the storage layer while Team B builds new OSINT adapters, because module boundaries ensure isolation.

4. Incremental Platform Evolution

The platform evolved through 19 generations without requiring full rewrites. Modularity enabled incremental improvement -- individual applications could be rewritten, optimized, or replaced while the rest of the system continued operating unchanged.

5. Quality Gate Isolation

The quality gates system (mix quality.gates) can analyze and enforce quality standards on a per-application basis. Failing quality in one module does not block development in unrelated modules, enabling parallel quality improvement.

  • Composability -- the ability to combine modular components into larger structures, building on modularity's independent units
  • Adapter Pattern -- a specific design pattern enabling modularity through interface-based backend swapping
  • Bounded Context -- Domain-Driven Design concept that informs where module boundaries should be drawn
  • Umbrella Application -- Elixir's primary mechanism for structural modularity in large codebases
  • Dependency Injection -- technique for decoupling modules by injecting dependencies rather than hardcoding them
  • Behaviour -- Elixir's compile-time contract mechanism that enforces modular interface compliance
  • Domain-Driven Design -- strategic design approach that uses bounded contexts to guide modular decomposition
  • OTP Application -- the runtime unit of modularity in Erlang/OTP systems, with its own supervision tree
  • Microservices -- an alternative modularity strategy using network-separated services
  • Protocol -- Elixir's data-type-based polymorphism mechanism complementing behaviour-based modularity
  • Supervision Tree -- OTP's hierarchical fault isolation structure that reinforces module independence

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL