Loading learning content...
Before we can invert dependencies, we must understand what we're inverting. DIP speaks of "high-level" and "low-level" modules, but these terms are often used vaguely or confused with layer names. In this page, we'll develop a precise, operational understanding of these concepts.
The distinction between high-level and low-level isn't about where code lives in a directory tree or which framework it uses. It's about semantic level — how close the code is to expressing business intent versus implementing technical mechanisms.
By the end of this page, you will be able to identify high-level and low-level modules in any codebase. You'll understand why this classification matters for dependency direction, how to protect high-level modules from low-level volatility, and how to apply this thinking to real architectural decisions.
A high-level module encapsulates the policy of your application — the business rules, domain logic, and core use cases that define what your software does. These modules express the reason your software exists.
Characteristics of High-Level Modules:
Examples of High-Level Modules:
Notice that these examples describe business capabilities, not technical implementations. An Order Processing Service doesn't know whether data lives in PostgreSQL or DynamoDB — it knows that orders have states, rules, and workflows.
A useful heuristic: Can you explain this module to a non-technical stakeholder? If you can describe what it does without mentioning databases, APIs, or frameworks, it's likely a high-level module. 'It manages customer orders and ensures business rules are followed' is high-level. 'It queries the orders table and maps rows to objects' is low-level.
A low-level module implements the mechanism — the technical details of how operations are performed. These modules interact with external systems, handle I/O, and translate between the abstract world of business logic and the concrete world of infrastructure.
Characteristics of Low-Level Modules:
Examples of Low-Level Modules:
These modules are the "plumbing" of your system. They're essential, but they shouldn't contaminate business logic with their technical details.
"Low-level" doesn't mean unimportant. These modules require significant expertise — database optimization, distributed systems knowledge, security awareness. The distinction is about semantic level (business vs technical), not importance or difficulty. Infrastructure code can be highly sophisticated; it's 'low-level' because it deals with mechanism rather than policy.
While we often speak of "high" and "low" as if they were opposite ends with nothing between, reality is more nuanced. Module levels exist on a spectrum, and a module's classification depends on what you're comparing it to.
Consider an e-commerce system with these layers:
┌───────────────────────────────────────────────────────────────────┐
│ User Interface (Controllers, Views) │ ← Presentation
├───────────────────────────────────────────────────────────────────┤
│ Application Services (Use Cases, Orchestration) │ ← Application
├───────────────────────────────────────────────────────────────────┤
│ Domain Services & Entities (Business Rules, Domain Logic) │ ← Domain (Core)
├───────────────────────────────────────────────────────────────────┤
│ Repositories, Gateways (Data Access Abstractions) │ ← Domain Interface
├───────────────────────────────────────────────────────────────────┤
│ Infrastructure Implementations (Database, Cache, APIs) │ ← Infrastructure
└───────────────────────────────────────────────────────────────────┘
Relative Perspectives:
| Module | Higher-Level Than | Lower-Level Than |
|---|---|---|
| OrderController | HTTP Middleware, Routing | User Intent/Action |
| PlaceOrderUseCase | OrderService, PaymentService | OrderController |
| OrderService (Domain) | OrderRepository interface | PlaceOrderUseCase |
| OrderRepository (Interface) | PostgresOrderRepository | OrderService |
| PostgresOrderRepository | pg library, SQL | OrderRepository interface |
Practical Implication:
When applying DIP, consider the local relationship between modules. The question isn't "Is this module high-level in absolute terms?" but "Is this module higher-level than the one it's about to depend on?" If yes, introduce an abstraction owned by the higher-level module.
Source code dependencies should flow from lower-level modules toward higher-level modules (through abstractions the higher-level owns). If you find a higher-level module importing from a lower-level module's package, that's a DIP violation waiting to create problems.
How do you determine whether a specific module is high-level or low-level in practice? Here are concrete techniques:
Technique 1: The "Why Does It Change?" Analysis
Examine what forces cause the module to be modified:
| If the module changes because... | It's likely... |
|---|---|
| Business rules evolve | High-level |
| User requirements shift | High-level |
| Database technology changes | Low-level |
| External API is updated | Low-level |
| Performance must be optimized | Low-level (usually) |
| New use case is added | High-level |
| Security vulnerability in library | Low-level |
Technique 2: The Import Analysis
Look at what a module imports:
// LOW-LEVEL SIGNALS: These imports suggest infrastructure
import { Pool } from 'pg'; // Database driver
import Stripe from 'stripe'; // External service SDK
import { S3Client } from '@aws-sdk/client-s3'; // Cloud provider
import express from 'express'; // Web framework
// HIGH-LEVEL SIGNALS: These imports suggest domain
import { Order, OrderStatus } from './domain/order';
import { Customer } from './domain/customer';
import { PricingRules } from './domain/pricing';
import { ValidationResult } from './domain/validation';
A module that imports mainly domain types is likely high-level. A module that imports libraries, SDKs, and drivers is likely low-level.
Technique 3: The Vocabulary Test
Read the code and note the vocabulary:
// HIGH-LEVEL VOCABULARY (Domain language)
await orderService.placeOrder(customer, items, shippingPreference);
if (order.requiresApproval()) {
await approvalWorkflow.initiate(order, approvers);
}
const invoice = billingService.generateInvoice(order);
// LOW-LEVEL VOCABULARY (Technical language)
await pool.query('INSERT INTO orders (id, customer_id) VALUES ($1, $2)', [orderId, customerId]);
const response = await axios.post('https://api.stripe.com/v1/charges', { amount });
await s3.putObject({ Bucket: 'invoices', Key: `${orderId}.pdf`, Body: pdfBuffer });
High-level code uses domain terms (order, customer, approval, invoice). Low-level code uses technical terms (query, response, bucket, buffer).
Understanding and maintaining the distinction between high-level and low-level modules isn't academic — it has profound practical consequences for your system's evolution:
1. Change Rate Asymmetry
High-level and low-level modules change at different rates and for different reasons:
When these are coupled, every change is harder than it needs to be. A database upgrade shouldn't require testing business logic. A new business rule shouldn't require understanding database transaction semantics.
2. Independent Deployability
Properly separated modules can be deployed independently:
This enables safer rollouts, faster iteration, and blue-green deployments at the infrastructure level.
3. Team Autonomy
Large organizations can structure teams around these boundaries:
Clear abstractions at the boundary enable parallel work without constant coordination.
| Scenario | With Separation | Without Separation |
|---|---|---|
| Database migration | Switch repository implementation | Modify business services, domain entities, and tests |
| New payment provider | Add new gateway implementation | Change order service, checkout flow, and validation logic |
| Performance optimization | Optimize infrastructure code | Refactor across layers, risking business logic bugs |
| Unit testing business logic | Mock repository interfaces | Set up real database or complex stubs |
| Onboarding new developer to domain | Read domain code in isolation | Navigate through database queries and HTTP handling |
When high-level depends directly on low-level, you pay a tax on every change. Simple modifications become archaeology expeditions. Testing requires infrastructure. Developers need to understand the entire stack for local changes. This coupling compounds over time, eventually paralyzing the team.
Let's examine a complete example showing proper high/low-level separation with DIP applied. Consider a subscription billing system:
The Business Requirement:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// ═══════════════════════════════════════════════════════════════════// HIGH-LEVEL MODULE: Domain Layer (Pure Business Logic)// ═══════════════════════════════════════════════════════════════════ // Domain Entities - Express business conceptsclass Subscription { constructor( public readonly id: string, public readonly customerId: string, public readonly planId: string, public status: SubscriptionStatus, public currentPeriodEnd: Date, public readonly paymentMethodId: string, ) {} isRenewable(): boolean { return this.status === 'active' || this.status === 'past_due'; } hasExpired(): boolean { return this.currentPeriodEnd < new Date(); } markPaid(newPeriodEnd: Date): void { this.status = 'active'; this.currentPeriodEnd = newPeriodEnd; } markPaymentFailed(attemptCount: number): void { this.status = attemptCount >= 3 ? 'unpaid' : 'past_due'; }} // Domain abstractions - Define what the domain NEEDS (owned by domain layer)interface SubscriptionRepository { findDueForRenewal(asOf: Date): Promise<Subscription[]>; save(subscription: Subscription): Promise<void>;} interface PaymentGateway { charge(request: ChargeRequest): Promise<ChargeResult>;} interface CustomerNotifier { notifyPaymentSuccess(customerId: string, receipt: PaymentReceipt): Promise<void>; notifyPaymentFailure(customerId: string, failure: PaymentFailure): Promise<void>;} interface BillingPlanCatalog { getPlan(planId: string): Promise<BillingPlan>;} // Domain Service - Implements business logic using abstractionsclass BillingService { constructor( private subscriptions: SubscriptionRepository, private payments: PaymentGateway, private notifier: CustomerNotifier, private plans: BillingPlanCatalog, ) {} async processMonthlyBilling(): Promise<BillingResult> { const due = await this.subscriptions.findDueForRenewal(new Date()); const results: IndividualBillingResult[] = []; for (const subscription of due) { const result = await this.billSubscription(subscription); results.push(result); } return new BillingResult(results); } private async billSubscription(subscription: Subscription): Promise<IndividualBillingResult> { const plan = await this.plans.getPlan(subscription.planId); const chargeResult = await this.payments.charge({ customerId: subscription.customerId, paymentMethodId: subscription.paymentMethodId, amount: plan.monthlyPrice, description: `${plan.name} - Monthly subscription`, }); if (chargeResult.success) { const newPeriodEnd = this.calculateNewPeriodEnd(subscription); subscription.markPaid(newPeriodEnd); await this.subscriptions.save(subscription); await this.notifier.notifyPaymentSuccess( subscription.customerId, chargeResult.receipt! ); return { subscriptionId: subscription.id, success: true }; } else { subscription.markPaymentFailed(chargeResult.attemptCount); await this.subscriptions.save(subscription); await this.notifier.notifyPaymentFailure( subscription.customerId, chargeResult.failure! ); return { subscriptionId: subscription.id, success: false, error: chargeResult.failure }; } } private calculateNewPeriodEnd(subscription: Subscription): Date { // Business logic for period calculation const current = subscription.currentPeriodEnd; return new Date(current.setMonth(current.getMonth() + 1)); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// ═══════════════════════════════════════════════════════════════════// LOW-LEVEL MODULE: Infrastructure Layer (Technical Implementation)// ═══════════════════════════════════════════════════════════════════ import { Pool } from 'pg';import Stripe from 'stripe';import { SES } from '@aws-sdk/client-ses'; // Implements SubscriptionRepository (domain abstraction) using PostgreSQLclass PostgresSubscriptionRepository implements SubscriptionRepository { constructor(private pool: Pool) {} async findDueForRenewal(asOf: Date): Promise<Subscription[]> { const result = await this.pool.query( `SELECT id, customer_id, plan_id, status, current_period_end, payment_method_id FROM subscriptions WHERE status IN ('active', 'past_due') AND current_period_end <= $1`, [asOf] ); return result.rows.map(row => this.toSubscription(row)); } async save(subscription: Subscription): Promise<void> { await this.pool.query( `UPDATE subscriptions SET status = $1, current_period_end = $2, updated_at = NOW() WHERE id = $3`, [subscription.status, subscription.currentPeriodEnd, subscription.id] ); } private toSubscription(row: any): Subscription { return new Subscription( row.id, row.customer_id, row.plan_id, row.status, row.current_period_end, row.payment_method_id ); }} // Implements PaymentGateway (domain abstraction) using Stripeclass StripePaymentGateway implements PaymentGateway { private stripe: Stripe; constructor(apiKey: string) { this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' }); } async charge(request: ChargeRequest): Promise<ChargeResult> { try { const paymentIntent = await this.stripe.paymentIntents.create({ amount: request.amount.cents, currency: request.amount.currency, payment_method: request.paymentMethodId, customer: request.customerId, description: request.description, confirm: true, }); return { success: true, receipt: { transactionId: paymentIntent.id, amount: request.amount }, attemptCount: 1, }; } catch (error) { return { success: false, failure: { reason: 'Payment declined', code: error.code }, attemptCount: 1, }; } }} // Implements CustomerNotifier (domain abstraction) using AWS SESclass SesCustomerNotifier implements CustomerNotifier { constructor(private ses: SES, private templateService: EmailTemplateService) {} async notifyPaymentSuccess(customerId: string, receipt: PaymentReceipt): Promise<void> { const email = await this.templateService.render('payment-success', { receipt }); await this.ses.sendEmail({ Source: 'billing@company.com', Destination: { ToAddresses: [await this.getCustomerEmail(customerId)] }, Message: { Subject: { Data: 'Payment Received' }, Body: { Html: { Data: email } } }, }); } async notifyPaymentFailure(customerId: string, failure: PaymentFailure): Promise<void> { const email = await this.templateService.render('payment-failure', { failure }); await this.ses.sendEmail({ Source: 'billing@company.com', Destination: { ToAddresses: [await this.getCustomerEmail(customerId)] }, Message: { Subject: { Data: 'Payment Issue' }, Body: { Html: { Data: email } } }, }); } private async getCustomerEmail(customerId: string): Promise<string> { // Fetch from customer service }}Key Observations:
BillingService (high-level) knows nothing about PostgreSQL, Stripe, or SES. It uses abstractions that express domain needs.
Infrastructure classes implement those abstractions, containing all technical details.
Dependency direction: Infrastructure imports from domain (to implement interfaces). Domain never imports from infrastructure.
Swappability: Switch from Stripe to Braintree? Implement a new BraintreePaymentGateway. BillingService doesn't change.
Testability: Test BillingService with mock implementations. No database, no HTTP, no email server required.
Robert C. Martin often describes the high-level/low-level distinction using the terms policy and mechanism. Understanding this vocabulary deepens your grasp of DIP.
Policy: The business rules, decisions, and logic that define what your system does. Policy answers "what should happen?" and "under what conditions?"
Mechanism: The technical implementation that makes policy executable. Mechanism answers "how do we actually do it?" at the technical level.
Examples of Policy:
Examples of Mechanism:
The DIP principle in these terms:
Policy should not depend on mechanism. Both should depend on abstractions that express policy needs.
This framing emphasizes that the business rules are the stable core. The mechanisms that execute those rules are volatile and interchangeable. When policy depends directly on mechanism, changing the mechanism forces changes to policy — which is backwards and dangerous.
| Business Capability | Policy (High-Level) | Mechanism (Low-Level) |
|---|---|---|
| Order Fulfillment | Rules for when orders can ship | Warehouse API integration for inventory check |
| User Authentication | Password complexity requirements | bcrypt hashing and JWT token generation |
| Notification Delivery | When and what to notify users | SendGrid API, Firebase push, Twilio SMS |
| Search | Relevance ranking rules | Elasticsearch queries and index configuration |
| Rate Limiting | Limits per tier and endpoint | Redis sliding window counter implementation |
Understanding high-level and low-level modules is foundational to applying DIP effectively. Let's consolidate what we've learned:
What's Next:
Now that we can identify high-level and low-level modules, we'll examine the traditional dependency structure and how DIP inverts it. Understanding this transformation is key to applying DIP in your own architectures.
You can now classify modules as high-level or low-level using concrete techniques. You understand why this distinction matters for system evolution and how it relates to the policy-mechanism separation. Next, we'll see exactly how traditional dependencies compare to inverted dependencies.