Reading the Graph: The Upward Arrow and Abstraction Layer Violations

How implementation code that reaches into higher-level business logic silently erodes your architecture—and how to find, diagnose, and fix abstraction layer violations using dependency graph analysis.

•19 min read
upward arrowabstraction layersdependency graph analysislayered architecturedependency inversionrefactoringcode complexitycodebase analysis

Every architecture has an intended direction.

In a layered codebase, entry points call into application services. Services coordinate domain behavior. Domain code expresses business concepts and policies. Infrastructure implements lower-level concerns like persistence, network clients, queues, caches, configuration, and framework integration.

The exact names vary by framework. Some teams call them handlers, use cases, entities, adapters, repositories, ports, or clients. The important part is not the terminology. The important part is that the architecture has a dependency direction, and that direction is supposed to mean something.

When implementation code reaches upward into business workflows, domain-specific decision logic, or higher-level application services, the boundary has been violated.

In a dependency graph, these violations appear as arrows pointing in the wrong direction. Not with the intended layer flow, but against it.

That is the Upward Arrow.

It applies across languages and frameworks: Python Django apps, TypeScript Node.js services, Go microservices, Java Spring applications, and mixed-service monoliths all develop Upward Arrows through the same mechanisms. It is one of the hardest architectural problems to catch in code review because the import statement that creates the violation looks completely ordinary.


What Clean Layer Boundaries Actually Look Like

Before identifying violations, it helps to define what "upward" means.

"Upward" is not universal. It depends on the layer map for your codebase. A Django app, a Clean Architecture service, a Rails monolith, and a TypeScript backend may all draw their boundaries differently. But once you define the intended direction, an Upward Arrow is any dependency that points against it.

A conventional layered application often looks something like this:

Entry Points / Delivery
API handlers, CLI commands, event consumers
        ↓
Application Services / Use Cases
workflow coordination, orchestration, transactions
        ↓
Domain Concepts / Business Policies
entities, value objects, rules, state transitions
        ↓
Infrastructure Contracts / Stable Boundaries
ports, repository interfaces, gateway interfaces

Concrete infrastructure implementations usually sit at the outside edge of this structure. They implement storage, network, queue, file, cache, framework, and external API concerns. They may implement a contract used by the application, but they should not need to import business workflows or domain-specific decision logic in order to function.

A violated version looks different:

Entry Points
        ↓
Services / Use Cases  ←────────────── Infrastructure Implementation
        ↓                              ↑
Domain Concepts ──────────────────────┘
        ↓
Infrastructure Contracts

The problem is not that an infrastructure module exists. The problem is that a lower-level implementation detail now knows about a higher-level business concept.

A database session factory imports from services/auth.py. A configuration module imports SubscriptionPlan from the domain layer. A queue consumer imports a use-case class to decide how messages should be routed. A utility module imports a service because that service had a helper function that was convenient to reuse.

Each individual import may look harmless. But in the graph, the direction is wrong.

In pvizgenerator.com, this usually shows up when an infrastructure-looking module has internal dependencies that reach into service, domain, handler, or workflow modules. The importer list reveals the same issue from the other side: a domain or service module is being imported by modules that should be below or outside it.

Each individual arrow that violates the intended direction is an Upward Arrow. The pattern name refers to the accumulation: a codebase where these violations are numerous enough that the layer structure no longer describes how the system actually depends on itself.


How Upward Arrows Form

The Convenient Shortcut

The most common origin is also the hardest to detect in code review.

A developer needs a business concept inside an infrastructure module. The domain layer already has a well-defined, tested representation of that concept. Importing it directly is two lines of code. Defining a boundary, passing a value in from above, or creating a neutral abstraction takes more work.

The diff looks reasonable. The imported symbol exists. The tests pass. The pull request merges.

The Upward Arrow now exists.

This repeats because different developers face the same incentive structure. Over months, infrastructure modules accumulate imports from domain modules that "happened to have exactly what was needed." Each decision was locally defensible. The aggregate is architectural drift.

The Accumulating Configuration Module

Configuration modules often start as simple key-value stores: environment variables, feature flags, connection strings, and runtime constants.

Over time, they accumulate decision logic. Environment-based switching. Context-aware routing. Behavior changes based on runtime state. Special cases for enterprise accounts, internal users, trial plans, or legacy customers.

Eventually, the configuration module imports from domain models or service classes to make those decisions. A config.py that started as this:

DB_URL = os.environ.get("DATABASE_URL")

becomes a module that imports UserTier from domain/users.py to decide which database pool to use.

The configuration module now lives in an infrastructure-adjacent part of the codebase, but it knows about a business concept. It has become an Upward Arrow that almost no one noticed forming.

A config.py module in the infrastructure layer with three upward arrows pointing to handlers/, services/, and domain/ nodes in their respective zones — illustrating accumulated layer violations

Business Logic in ORM Models

ORM models are one of the most common places for layer boundaries to blur.

In some frameworks, ORM models are treated as domain objects. In others, they are treated as persistence objects. In many real codebases, they become both. That ambiguity is exactly why they frequently create Upward Arrows.

A database model starts by mapping rows to objects. Then it gets validation methods. Then state transition methods. Then computed properties. Then lifecycle hooks. Then a method that triggers a workflow when a record reaches a certain state.

Once an ORM model contains meaningful business behavior, that behavior often needs to import from higher-level domain or service modules.

An OrderRecord model's complete_order() method imports from domain/inventory.py to check stock. A UserModel lifecycle hook imports from services/email.py to send onboarding messages. A SubscriptionRow helper imports from pricing rules to decide how billing should behave.

The persistence object now reaches upward into business logic. The arrow points against the intended boundary.

The Growing Event Handler

Event-driven systems frequently develop Upward Arrows as routing logic grows more complex.

An event processor starts by deserializing messages and dispatching to handlers. Then it accumulates conditional logic: if the event is type X and the payload contains field Y, route to handler Z. But if feature flag F is enabled, route to handler W. Unless the account is enterprise. Unless the event came from a legacy integration.

That routing logic requires knowledge of business concepts: event types, feature flags, account state, product tier, workflow state, and exception rules.

But the event processor often sits close to the message bus. It is treated as infrastructure. The business rules accumulate there because that is where the router lives.

The result is an infrastructure module that has become a de facto business rules engine.

Cross-Service Coupling Through Shared Infrastructure

In systems where multiple services share a database, message bus, event stream, or internal platform library, infrastructure-level components sometimes import domain concepts from multiple services to coordinate between them.

A shared event processor imports from Service A's domain model and Service B's domain model to route messages between them. A shared database utility imports several product-specific enums to decide where records should be written. A shared client wrapper imports business-specific retry rules from individual services.

This creates Upward Arrows not just within services, but across service boundaries. The shared infrastructure module becomes the point where service isolation breaks down because the infrastructure layer now knows about the domain of each service it connects to.


Why Upward Arrows Are Dangerous

Hidden Coupling That Bypasses Code Review

An Upward Arrow creates coupling that is invisible to the mental model developers use when changing code.

When a developer modifies a domain model, they expect services, handlers, and workflows above that model to be affected. They do not usually expect database clients, queue adapters, config modules, or low-level utilities to break because those modules are not supposed to depend on the domain model in the first place.

But if an infrastructure module has an Upward Arrow to that domain model, it is part of the blast radius too.

That effect bypasses the developer's impact assessment. The change looked local. The dependency graph says otherwise.

This is the particular danger of Upward Arrows compared to other coupling problems. A Star or an Hourglass is dangerous because coupling is visibly concentrated. Upward Arrows are dangerous because they make coupling invisible behind a boundary everyone assumes still exists.

Stability Inversion

The value of layering is partly stability.

Lower-level contracts and implementation details are supposed to be more stable than upper-level workflows. Your database connection code should not need to change every time product requirements change. Your HTTP client wrapper should not change every time a use case is renamed. Your configuration module should not break because a domain model was refactored.

Upward Arrows invert that relationship.

When infrastructure depends on business logic, it inherits the instability of the business layer. The database module now needs to be updated when business rules change. The configuration module breaks when a domain concept is renamed. The queue adapter has to change because a service workflow was reorganized.

The stability that layering was supposed to provide disappears.

In a codebase with significant Upward Arrow accumulation, no layer is truly stable. Changes ripple in every direction because the dependency structure no longer constrains propagation.

Testing Complexity Expansion

Testing an infrastructure module should usually be straightforward. You mock the database, external API, queue, cache, or file system and test the behavior in isolation.

An infrastructure module with an Upward Arrow is harder to test because it drags higher-level business dependencies into the test setup.

A database session factory that imports from services/auth.py may now require auth configuration, user models, token policies, and repository dependencies. A queue adapter that imports a domain workflow may require most of the workflow's dependency tree. A config module that imports product-tier logic may require the same setup as a service-level test.

The practical consequence is predictable: teams stop writing granular infrastructure tests because the setup is too expensive. Infrastructure is tested only through integration tests or end-to-end tests. Feedback gets slower. Failures become harder to isolate.

The import was small. The test surface became large.

A Reliable Precursor to Circular Dependencies

Upward Arrows are common precursors to circular dependencies.

If db/session.py imports from services/auth.py, and services/auth.py imports from db/session.py to manage database access, the Upward Arrow has closed a cycle that previously did not exist.

Not every Upward Arrow immediately creates a circular dependency. But each one reduces the margin. The more Upward Arrows exist, the fewer changes are needed to produce a cycle.

A Mesh pattern — the severe form of coupling where everything starts depending on everything — is rarely created in one dramatic rewrite. It usually develops through accumulating boundary violations. Upward Arrows blur the layers first. Cycles and mesh-like structure follow.


Identifying Upward Arrows in Your Codebase

The challenge with Upward Arrows is that they are not visible from a single import statement.

At the code level, the imported module exists. The name is clear. The import works. The tests pass. The violation is architectural, which means it is only visible in the context of the layer map and the whole dependency graph.

Define Your Layers Explicitly

The first step is writing down your expected layer structure.

This does not require a formal architecture document. A rough directory-to-layer map is enough:

  • Entry Points: api/, routes/, controllers/, handlers/, consumers/, cli/
  • Services / Use Cases: services/, use_cases/, workflows/, application/
  • Domain: domain/, entities/, models/, policies/, rules/
  • Infrastructure: db/, repositories/, clients/, adapters/, config/, persistence/, messaging/
  • Shared Contracts / Ports: ports/, interfaces/, contracts/, protocols/

Once the map exists, an Upward Arrow is any import from a lower-level or implementation-oriented module to a higher-level business module.

A file in db/ importing from services/ is an Upward Arrow. A file in config/ importing from domain/ is probably an Upward Arrow. A shared adapter importing from multiple product-specific domain modules is an Upward Arrow across service boundaries.

The key is not whether the directory names are perfect. The key is that you have made the intended dependency direction explicit enough to compare the graph against it.

What to Look For in Your PViz Bundle

When you run PViz on a Python, TypeScript, Go, or Java codebase, the output bundle contains dependency data for every module: what each module imports and what imports it.

For Upward Arrow detection, start with infrastructure-looking modules.

Look for db/, clients/, repositories/, adapters/, config/, persistence/, and messaging/ modules with unexpectedly long internal dependency lists. Infrastructure modules should usually depend on external libraries, stable contracts, or adjacent infrastructure helpers. When they import heavily from internal business modules, something has probably crossed the boundary.

Then inspect from the other direction.

Look at domain and service modules with broad importer lists. If a core domain model is imported by database clients, queue adapters, config modules, and framework integration files, those are likely Upward Arrows.

The SCC data is a downstream signal. If infrastructure modules appear in the same strongly connected component as domain or service modules, Upward Arrows have already helped close circular dependency cycles in that cluster.

Keyword Signals in Infrastructure Files

A fast manual scan is to look for business vocabulary inside infrastructure files.

Terms like UserStatus, OrderState, PaymentMethod, SubscriptionPlan, InventoryCheck, PricingTier, TrialAccount, or CancellationPolicy appearing under db/, clients/, config/, or adapters/ are strong signals that business concepts have leaked into implementation code.

This will not find every Upward Arrow, but it surfaces the most obvious cases quickly: infrastructure modules that have absorbed business meaning they should not own.

Static Analysis Enforcement

For teams that want prevention rather than periodic detection, static analysis can enforce layer rules directly:

  • TypeScript / JavaScript: dependency-cruiser can define forbidden cross-layer imports
  • Python: import-linter can specify allowed and forbidden package dependencies
  • Go: go-layercheck or custom vet rules can enforce package import ordering
  • Java: ArchUnit can express architectural constraints as tests

These tools turn Upward Arrow detection into a continuous guardrail. Once existing violations are resolved, new ones fail before they merge.


Fixing Upward Arrows

Move the Decision to the Right Layer

The cleanest fix is often not an interface. It is moving the decision back to the layer that owns it.

If an infrastructure module imports a domain concept because it needs to make a business decision, ask whether that decision belongs in infrastructure at all.

For example, a database session factory probably should not decide pool size based on UserTier. That is a product or application policy. A higher-level service can decide the intended policy and pass a simple value downward.

# Before: infrastructure reaches upward into domain
# db/session_factory.py
from domain.users import UserTier  # UPWARD ARROW

def get_pool_size(user: Any) -> int:
    if user.tier == UserTier.ENTERPRISE:
        return 20
    return 5

A cleaner version moves the business decision out of the database module:

# services/session_policy.py
from domain.users import UserTier

def pool_size_for_user(user: User) -> int:
    if user.tier == UserTier.ENTERPRISE:
        return 20
    return 5
# db/session_factory.py

def create_session_pool(pool_size: int) -> SessionPool:
    return SessionPool(size=pool_size)

Now the service layer decides policy. The infrastructure layer receives a plain value and implements the low-level operation. The Upward Arrow is gone.

Dependency Inversion Through a Neutral Contract

Sometimes infrastructure genuinely needs to call behavior supplied by another layer. In that case, dependency inversion can remove the upward import.

The important detail is where the interface lives. If you put the interface in db/interfaces.py, domain code importing it will look like domain importing database infrastructure — fixing the immediate arrow while creating a different boundary smell.

A better approach is to place the contract in a neutral port or protocol module:

# ports/session_policy.py
from typing import Protocol

class TierProvider(Protocol):
    def get_tier_name(self) -> str: ...
# db/session_factory.py
from ports.session_policy import TierProvider

def get_pool_size(user: TierProvider) -> int:
    if user.get_tier_name() == "enterprise":
        return 20
    return 5
# domain/users.py
class User:
    def get_tier_name(self) -> str:
        return self.tier.value

The infrastructure module no longer imports domain.users.UserTier. It depends on a small neutral contract. The domain object satisfies that contract without the infrastructure implementation knowing about the domain type.

This is not always the best fix. If the infrastructure module is still making a business decision, moving the decision upward is usually cleaner. But when a genuine boundary contract is needed, neutral ports are safer than having infrastructure import domain code directly.

Introduce a Translation Layer

When infrastructure needs to interact with business concepts at a boundary — especially with ORM mapping — a translation layer is often the most maintainable solution.

The ORM model can remain a persistence concern: columns, indexes, relations, query optimization, and storage representation.

The domain model can remain a business concern: invariants, policies, state transitions, and meaningful behavior.

A mapper sits between them:

Domain Model  ←→  Mapper / Translator  ←→  ORM Model

The mapper knows about both sides because translation is its explicit job. The ORM model does not need to contain business workflows. The domain model does not need to know about database columns. The boundary becomes visible instead of implicit.

This adds code, but the code is doing real architectural work — handling the mismatch between storage representation and domain representation rather than hiding an Upward Arrow inside an ORM method.

Split Shared Infrastructure by Ownership

When a shared infrastructure module imports domain concepts from several services, the issue is often ownership.

A shared message router that knows about five product domains is not really shared infrastructure anymore. It is cross-service orchestration disguised as infrastructure.

Possible fixes include:

  • Move routing decisions into each service and keep the shared router generic
  • Split the shared module into service-specific adapters
  • Define message contracts in a neutral package instead of importing service-specific domain models
  • Push coordination into an application-level orchestrator rather than a low-level infrastructure helper

The goal is not to eliminate sharing. The goal is to prevent shared infrastructure from becoming the place where every service's business logic accumulates.

Enforce the Boundary Going Forward

Fixing existing Upward Arrows without preventing new ones is temporary.

The same convenience pressure that created the violations will create them again. Once the worst arrows are cleaned up, add enforcement.

Static analysis rules in CI are the most reliable mechanism. A developer who accidentally imports from services/ inside db/ should get immediate feedback at the pull request stage, not three months later during an architecture review.

The graph finds the pattern. Enforcement keeps it from returning.


The Test for a Clean Layer Boundary

There is a practical test for whether a boundary is actually clean:

The replaceability test: Could you replace this infrastructure implementation with another one without rewriting your domain rules or application workflows?

Swap Postgres for another persistence implementation. Replace one message broker with another. Change an external API client. Move from one cache provider to another.

Some wiring will change. Some adapter code will change. That is expected.

But if replacing infrastructure requires editing domain rules, service workflows, or business policies, the infrastructure layer was not isolated. It had knowledge flowing in the wrong direction. Somewhere in the graph, Upward Arrows existed.

This is the practical payoff of eliminating them. It is not an abstract purity exercise. It is the difference between a database migration that requires changing adapters and one that requires untangling product behavior from persistence objects. It is the difference between swapping an API client in a day and spending weeks discovering which business rules were hidden inside the client wrapper.

A clean boundary makes implementation replaceable.

An Upward Arrow makes implementation part of the business model.


Conclusion

Upward Arrows are invisible in code review and obvious in a dependency graph.

The import statement that creates one looks ordinary. A module needed a symbol, imported it, passed tests, and moved on. Only when you see the dependency direction across the whole codebase does the violation become visible.

Layer violations compound over time. Each new Upward Arrow makes the next one easier to justify because the boundary has already been crossed. A developer who sees that db/session.py already imports from domain/users.py has much weaker reason to avoid adding another cross-layer import.

The boundary erosion accelerates.

The graph makes that erosion visible before it becomes too expensive to address. A codebase where three infrastructure modules reach upward into business logic is a straightforward cleanup. A codebase where infrastructure, domain, services, and entry points all depend on each other is months of careful untangling.

The difference is usually whether anyone looked at the arrows early enough.

Visualize your architecture's layer violations at pvizgenerator.com →


Published in the Reading the Graph Series

The Star: When One Module Knows Too Much
High fan-in, high fan-out, and the module your entire codebase orbits around

The Chain: Deep Dependency Paths and the Teams That Can't Ship Fast
How long linear dependency chains create invisible coupling between unrelated parts of your system

The Mesh: When Everything Depends on Everything
What to do when your dependency graph stops having structure

The Hourglass: Your Most Dangerous Module Is Probably One You've Never Thought About
High fan-in above, high fan-out below — and a single module holding it all together

The Island: Isolated Clusters and the Ownership Vacuum They Create
Why self-contained subsystems are more dangerous than they appear

The Creeping Utility: How Helper Modules Become Load-Bearing Walls
The lifecycle of utils.py and how to intervene before it's too late

The Hidden Seam: Using Your Dependency Graph to Find Microservice Boundaries
Graph structure reveals where your monolith already wants to split

Try PViz on your own codebase

Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.