Third-party libraries are implementation details. Your application's architecture should not become one.
You install one library today.
Two years later, 37 Angular files depend on it.
Now replacing it isn't a package upgrade. It's a migration project.
In enterprise Angular projects, a recurring architecture problem isn't the dependency itself. It's where the dependency is allowed to appear. This article walks through a practical, non-dogmatic approach to isolating dependencies that are expensive to change, with complete, modern Angular examples (standalone APIs, inject(), InjectionToken, strict TypeScript).
Table of Contents
- The Trap: npm install Is Easy
- What "Leaking" Actually Means
- The Core Principle: Control Dependency Direction
- Wrapper vs Adapter vs Facade
- Level 1: The Service Wrapper
- Level 2: Port + Adapter with InjectionToken
- Sizing the Port Correctly
- Provider-Level Configuration: provideAnalytics()
- The UI Boundary: Wrapping Vendor Components
- Browser Globals and InjectionToken
- Authentication Boundary
- Payment Boundary with a Facade
- Error Translation
- Testing: Mock Your Contract, Not the Vendor
- Security Considerations
- A Realistic Migration Scenario
- When NOT to Wrap
- Decision Framework and Checklist
- Comparison Table
- Common Mistakes
- The 10 Rules
- Conclusion
1. The Trap: npm Install Is Easy
The story usually goes like this:
npm install some-library
↓
import it
↓
use it everywhere
↓
everything works ✅
Then, two years later:
- a breaking change lands in a major version
- a security advisory requires an urgent upgrade
- the vendor becomes too expensive, or stops meeting requirements
- someone proposes migrating to an alternative
Nobody made a bad decision in isolation. Each import was reasonable. But the sum of those imports quietly turned a library choice into an architectural commitment.
2. What "Leaking" Actually Means
A dependency leaks when vendor-specific concepts appear throughout your application:
- vendor-specific types in function signatures
- vendor-specific error objects in
catchblocks - vendor-specific configuration duplicated across features
- vendor-specific method names in components
- vendor-specific event models in templates and handlers
- vendor-specific DTOs in your state
- vendor-specific component APIs (inputs/options objects) in many templates
Here's the leaking version:
import { thirdPartyAnalytics } from 'vendor-analytics';
@Component({
selector: 'app-checkout',
standalone: true,
template: `<button (click)="onPurchase()">Pay</button>`,
})
export class CheckoutComponent {
private order = inject(OrderStore);
onPurchase(): void {
thirdPartyAnalytics.track('purchase', {
order_id: this.order.id(),
value: this.order.total(),
currency: 'EUR',
});
}
}
This component now knows the vendor's import path, its event naming convention, its payload shape, and its global singleton. Multiply this by every feature that tracks anything.
3. The Core Principle: Control Dependency Direction
This is not about hiding every dependency. It's about controlling which way dependencies point.
Your application should depend on capabilities, not vendors.
DIRECT DEPENDENCY
Component
↓
Vendor API
ISOLATED DEPENDENCY
Component
↓
Application Interface
↓
Adapter / Service
↓
Vendor API
The second shape creates a seam. A seam is a place where you can change behavior without editing the code around it. It gives you room for:
- replacement
- testing
- centralized configuration
- monitoring
- error handling
- version upgrades
- vendor migration
Speak the application's language
| Vendor-shaped (avoid) | Application-shaped (prefer) |
|---|---|
stripe.createPaymentIntent() |
paymentService.createPayment() |
someAnalytics.captureEvent() |
analyticsService.track() |
fancyLog.captureException() |
loggerService.error() |
vendorChart.setSeries() |
salesChart.updateData() |
keycloak.hasRealmRole() |
authorizationService.hasPermission() |
thirdPartyMap.initialize() |
mapService.initialize() |
The application describes what it needs. The adapter translates that into vendor-specific calls.
A careful claim about migration
A boundary does not mean "changing a package always touches one file." A more honest claim: a well-designed boundary can reduce the migration surface dramatically. Actual effort depends on how much vendor behavior has already leaked into your application.
4. Wrapper vs Adapter vs Facade
These terms get mixed up. Here's a practical distinction:
- Wrapper: a thin, application-owned layer around a single library. Often just an Angular service or component. Good for consistent defaults and a single import site.
- Adapter: an implementation of an application-owned interface (a port) that translates to a specific vendor. Swap the adapter, keep the contract.
-
Facade: a simplified API over a workflow involving several services. It exists for feature ergonomics, not vendor isolation.
CheckoutFacademay coordinate cart, payment, and analytics.
They compose well:
CheckoutFacade
↓
PaymentService (port)
↓
StripeAdapter
↓
Stripe
The checkout feature shouldn't need to understand Stripe-specific concepts.
5. Level 1: The Service Wrapper
For many dependencies, a simple service is enough. It creates a single import site and an application-owned method vocabulary.
// analytics.service.ts
import { Injectable } from '@angular/core';
import { thirdPartyAnalytics } from 'vendor-analytics';
export interface PurchaseData {
orderId: string;
total: number;
currency: string;
}
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
trackPurchase(data: PurchaseData): void {
thirdPartyAnalytics.track('purchase', {
order_id: data.orderId,
value: data.total,
currency: data.currency,
});
}
}
The component now looks like this:
@Component({
selector: 'app-checkout',
standalone: true,
template: `<button (click)="onPurchase()">Pay</button>`,
})
export class CheckoutComponent {
private analytics = inject(AnalyticsService);
private order = inject(OrderStore);
onPurchase(): void {
this.analytics.trackPurchase({
orderId: this.order.id(),
total: this.order.total(),
currency: 'EUR',
});
}
}
The component knows application behavior. The service knows the vendor.
This alone often pays for itself. But notice the limit: AnalyticsService is still a concrete class that imports the vendor. To swap vendors you edit this file. If you also want per-environment implementations (no-op in dev, vendor in production, fake in tests), go one level deeper.
6. Level 2: Port + Adapter with InjectionToken
Now the application owns the contract, and infrastructure decides who fulfills it. This is dependency inversion applied to Angular DI.
// analytics.port.ts (application-owned; no vendor imports)
import { InjectionToken } from '@angular/core';
export interface AnalyticsEvent {
name: string;
properties?: Record<string, string | number | boolean>;
}
export interface AnalyticsPort {
track(event: AnalyticsEvent): void;
identify(userId: string): void;
}
export const ANALYTICS = new InjectionToken<AnalyticsPort>('ANALYTICS');
The vendor adapter (the only file that imports the vendor package):
// vendor-analytics.adapter.ts
import { Injectable } from '@angular/core';
import { thirdPartyAnalytics } from 'vendor-analytics';
import { AnalyticsEvent, AnalyticsPort } from './analytics.port';
@Injectable()
export class VendorAnalyticsAdapter implements AnalyticsPort {
track(event: AnalyticsEvent): void {
thirdPartyAnalytics.track(event.name, event.properties);
}
identify(userId: string): void {
thirdPartyAnalytics.setUser({ id: userId });
}
}
A no-op adapter for development, tests, or users who opted out of tracking:
// noop-analytics.adapter.ts
import { Injectable } from '@angular/core';
import { AnalyticsPort } from './analytics.port';
@Injectable()
export class NoopAnalyticsAdapter implements AnalyticsPort {
track(): void {}
identify(): void {}
}
Feature code depends only on the port:
@Component({ /* ... */ })
export class CheckoutComponent {
private analytics = inject(ANALYTICS);
onPurchase(): void {
this.analytics.track({
name: 'purchase_completed',
properties: { orderId: '42', total: 99.9 },
});
}
}
Application code depends on the capability. Infrastructure decides which provider implements it.
Should you name events in an application-owned taxonomy? Yes. purchase_completed is your event; the adapter can map it to the vendor's naming rules if needed.
7. Sizing the Port Correctly
The port is the hard part. Get its size wrong, and one of two things happens:
- Too close to the vendor, and replacing the vendor still means rewriting the contract — you've relocated the coupling, not removed it.
- Too ambitious, promising capabilities no provider can reliably deliver, and every adapter fills the gap with awkward workarounds.
A port is only a real boundary when it expresses what the application means by an operation — not a renamed version of what the vendor happens to expose.
// ❌ mirrors the vendor too closely — this is Stripe's status model, not the app's
export interface PaymentPort {
createPaymentIntent(amount: number): Promise<{ status: 'requires_confirmation' | 'succeeded' | 'requires_payment_method' }>;
}
// ✅ expresses what the application needs a payment to do
export interface PaymentPort {
createPayment(amount: number, currency: string): Promise<Payment>;
confirm(paymentId: string): Promise<Payment>;
}
In practice, the right size for a port rarely comes from designing it up front. It comes from building one adapter, then sketching a second before committing to the shape — a port shaped by a single vendor tends to quietly inherit that vendor's assumptions. Keep the contract to what the application genuinely needs, translate vendor-specific behavior and errors at the adapter boundary, and validate the port with the same contract tests (Section 13) run against every adapter. That combination — minimal contract, edge translation, shared tests — is what makes a port a seam instead of a disguise.
8. Provider-Level Configuration: provideAnalytics()
Sometimes the cleanest abstraction is a provider function. Bootstrapping configures infrastructure once; features never see configuration.
// analytics.providers.ts
import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
import { ANALYTICS } from './analytics.port';
import { NoopAnalyticsAdapter } from './noop-analytics.adapter';
import { VendorAnalyticsAdapter } from './vendor-analytics.adapter';
export interface AnalyticsConfig {
apiKey: string;
enabled: boolean;
}
export const ANALYTICS_CONFIG = new InjectionToken<AnalyticsConfig>('ANALYTICS_CONFIG');
export function provideAnalytics(config: AnalyticsConfig): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: ANALYTICS_CONFIG, useValue: config },
{
provide: ANALYTICS,
useClass: config.enabled ? VendorAnalyticsAdapter : NoopAnalyticsAdapter,
},
]);
}
Use it in app.config.ts:
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideAnalytics({
apiKey: environment.analyticsKey,
enabled: environment.production,
}),
// provideMonitoring({ ... }),
// providePayments({ ... }),
// provideMaps({ ... }),
],
};
This mirrors how Angular's own APIs are configured (provideRouter, provideHttpClient), so it will feel idiomatic to your team.
If the adapter needs configuration, inject the config token inside it:
@Injectable()
export class VendorAnalyticsAdapter implements AnalyticsPort {
private config = inject(ANALYTICS_CONFIG);
constructor() {
thirdPartyAnalytics.init({ apiKey: this.config.apiKey });
}
// track(), identify()...
}
Configuration is now centralized, not duplicated across features.
9. The UI Boundary: Wrapping Vendor Components
Vendor UI (charts, editors, date pickers, maps) is the second common leakage source. If every template uses the vendor's component API directly, every feature learns the vendor's options object, event model, and quirks.
The leaking version:
<!-- in 12 different features -->
<vendor-chart
[options]="{ series: [{ type: 'bar', data: sales }], tooltip: { trigger: 'axis' }, /* ... */ }"
(chartClick)="onClick($event)" />
The better version, an application-owned component API:
<app-sales-chart
[data]="sales()"
(pointSelected)="onPointSelected($event)" />
Inside, a single standalone component talks to the vendor:
// sales-chart.component.ts
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { VendorChartComponent, VendorClickEvent, VendorChartOptions } from 'vendor-chart';
export interface SalesPoint {
month: string;
amount: number;
}
@Component({
selector: 'app-sales-chart',
standalone: true,
imports: [VendorChartComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<vendor-chart
role="img"
[attr.aria-label]="ariaLabel()"
[options]="options()"
(chartClick)="onVendorClick($event)" />
`,
styles: `:host { display: block; min-height: 280px; }`,
})
export class SalesChartComponent {
data = input.required<SalesPoint[]>();
ariaLabel = input('Monthly sales chart');
pointSelected = output<SalesPoint>();
// Signals earn their place here: options are derived from input data.
protected options = computed<VendorChartOptions>(() => ({
series: [{ type: 'bar', data: this.data().map((p) => p.amount) }],
xAxis: { categories: this.data().map((p) => p.month) },
tooltip: { trigger: 'axis' },
}));
protected onVendorClick(event: VendorClickEvent): void {
// Translate vendor event → application type. Vendor types stop here.
const point = this.data()[event.dataIndex];
if (point) {
this.pointSelected.emit(point);
}
}
}
The application now owns:
-
Inputs (
data,ariaLabel) -
Outputs (
pointSelectedemitsSalesPoint, notVendorClickEvent) - Events
- Defaults
- Styling
- Accessibility
- Vendor configuration
Swap the chart library later, and the migration touches SalesChartComponent. Consumers keep the same inputs and outputs.
Wrap vendor UI when it becomes part of your product: when users know it as "the sales chart", not "the vendor's chart".
10. Browser Globals and InjectionToken
Scattering window.someLibrary across features is hard to test, hard to reason about under SSR, and hard to replace. Give the global one entry point:
// payments-sdk.token.ts
import { DOCUMENT } from '@angular/common';
import { InjectionToken, inject } from '@angular/core';
export interface PaymentsSdk {
openCheckout(sessionId: string): Promise<void>;
}
type WindowWithPayments = Window & { VendorPayments?: PaymentsSdk };
export const PAYMENTS_SDK = new InjectionToken<PaymentsSdk>('PAYMENTS_SDK', {
providedIn: 'root',
factory: () => {
const win = inject(DOCUMENT).defaultView as WindowWithPayments | null;
const sdk = win?.VendorPayments;
if (!sdk) {
throw new Error('Payments SDK is not available in this environment.');
}
return sdk;
},
});
Consumers:
@Injectable({ providedIn: 'root' })
export class PaymentLauncher {
private sdk = inject(PAYMENTS_SDK);
start(sessionId: string): Promise<void> {
return this.sdk.openCheckout(sessionId);
}
}
Tests replace the token, with no global patching:
TestBed.configureTestingModule({
providers: [
{
provide: PAYMENTS_SDK,
useValue: { openCheckout: jest.fn().mockResolvedValue(undefined) },
},
],
});
Benefits:
-
Testable: no
windowmutation across tests - Replaceable: swap the real SDK, a sandbox SDK, or a fake
- Environment-aware: handle SSR/browser differences in one place
11. Authentication Boundary
Authentication providers (Keycloak, Auth0, Okta) are strong isolation candidates: they hold security-critical behavior, need centralized configuration, and their concepts (realm roles, scopes, claims) tend to leak into guards and templates.
Define what the application needs:
// authorization.port.ts
import { InjectionToken } from '@angular/core';
export type Permission = 'orders:read' | 'orders:approve' | 'users:manage';
export interface AuthorizationPort {
isAuthenticated(): boolean;
hasPermission(permission: Permission): boolean;
login(): Promise<void>;
logout(): Promise<void>;
}
export const AUTHORIZATION = new InjectionToken<AuthorizationPort>('AUTHORIZATION');
The adapter maps the application's permission vocabulary to the vendor's:
// keycloak-authorization.adapter.ts
import { Injectable, inject } from '@angular/core';
import Keycloak from 'keycloak-js';
import { AuthorizationPort, Permission } from './authorization.port';
export const KEYCLOAK = new InjectionToken<Keycloak>('KEYCLOAK');
const PERMISSION_TO_ROLE: Record<Permission, string> = {
'orders:read': 'order-viewer',
'orders:approve': 'order-approver',
'users:manage': 'user-admin',
};
@Injectable()
export class KeycloakAuthorizationAdapter implements AuthorizationPort {
private keycloak = inject(KEYCLOAK);
isAuthenticated(): boolean {
return this.keycloak.authenticated === true;
}
hasPermission(permission: Permission): boolean {
return this.keycloak.hasRealmRole(PERMISSION_TO_ROLE[permission]);
}
login(): Promise<void> {
return this.keycloak.login();
}
logout(): Promise<void> {
return this.keycloak.logout();
}
}
A guard that knows nothing about Keycloak:
export const requirePermission = (permission: Permission): CanActivateFn => () => {
const auth = inject(AUTHORIZATION);
return auth.hasPermission(permission) || inject(Router).createUrlTree(['/forbidden']);
};
// routes
{ path: 'approvals', canActivate: [requirePermission('orders:approve')], loadComponent: () => ... }
Realm-role names now live in one mapping. If the identity provider changes, or roles are renamed, the blast radius is the adapter.
12. Payment Boundary with a Facade
Payments show how a port, an adapter, and a facade fit together.
// payment.port.ts
export interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'succeeded' | 'failed';
}
export abstract class PaymentService {
abstract createPayment(amount: number, currency: string): Promise<Payment>;
abstract confirm(paymentId: string): Promise<Payment>;
}
Using an abstract class as an injection token is idiomatic Angular, and it saves a separate InjectionToken. The adapter:
// stripe-payment.adapter.ts
@Injectable()
export class StripePaymentAdapter extends PaymentService {
private http = inject(HttpClient);
private stripe = inject(STRIPE_CLIENT); // token that wraps the browser SDK
async createPayment(amount: number, currency: string): Promise<Payment> {
// Vendor-specific calls and shapes live here only.
const intent = await firstValueFrom(
this.http.post<{ id: string; status: string }>('/api/payments', { amount, currency }),
);
return { id: intent.id, amount, currency, status: 'pending' };
}
async confirm(paymentId: string): Promise<Payment> {
try {
const result = await this.stripe.confirm(paymentId);
return this.toPayment(result);
} catch (e) {
throw toPaymentError(e); // see section 12
}
}
private toPayment(result: unknown): Payment { /* vendor → app mapping */ }
}
Provide it:
export function providePayments(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: PaymentService, useClass: StripePaymentAdapter },
]);
}
The facade orchestrates the workflow for the checkout feature:
@Injectable()
export class CheckoutFacade {
private payments = inject(PaymentService);
private cart = inject(CartStore);
private analytics = inject(ANALYTICS);
async startCheckout(): Promise<Payment> {
const payment = await this.payments.createPayment(this.cart.total(), this.cart.currency());
this.analytics.track({ name: 'checkout_started', properties: { amount: payment.amount } });
const confirmed = await this.payments.confirm(payment.id);
this.analytics.track({ name: 'checkout_completed', properties: { paymentId: confirmed.id } });
return confirmed;
}
}
The checkout feature talks to CheckoutFacade. It never sees a Stripe type.
13. Error Translation
Vendor error objects are a subtle but serious form of leakage. If your components catch and inspect StripeError.code, you're coupled.
Translate at the boundary:
// payment.errors.ts
export type PaymentFailureReason = 'declined' | 'insufficient_funds' | 'network' | 'unknown';
export class PaymentError extends Error {
constructor(public readonly reason: PaymentFailureReason, message: string) {
super(message);
this.name = 'PaymentError';
}
}
// inside the adapter
function toPaymentError(e: unknown): PaymentError {
const code = (e as { code?: string })?.code;
switch (code) {
case 'card_declined':
return new PaymentError('declined', 'The card was declined.');
case 'insufficient_funds':
return new PaymentError('insufficient_funds', 'Insufficient funds.');
default:
return new PaymentError('unknown', 'Payment failed.');
}
}
Features handle PaymentError, a type you own:
try {
await this.checkout.startCheckout();
} catch (e) {
if (e instanceof PaymentError && e.reason === 'declined') {
this.showMessage('Your card was declined. Try another card.');
}
}
Centralized translation also allows you to strip sensitive vendor details before errors reach logs or UI.
14. Testing: Mock Your Contract, Not the Vendor
Compare the two setups:
Component → Mock AnalyticsService ✅ simple, stable
Component → Mock Third-Party SDK ❌ vendor-shaped, repeated everywhere
Unit test with a fake port
describe('CheckoutComponent', () => {
it('tracks a purchase', () => {
const analytics: AnalyticsPort = { track: jest.fn(), identify: jest.fn() };
TestBed.configureTestingModule({
imports: [CheckoutComponent],
providers: [{ provide: ANALYTICS, useValue: analytics }],
});
const fixture = TestBed.createComponent(CheckoutComponent);
fixture.componentInstance.onPurchase();
expect(analytics.track).toHaveBeenCalledWith(
expect.objectContaining({ name: 'purchase_completed' }),
);
});
});
Contract tests
Run the same behavioral suite against every adapter, so a replacement is verified against the same expectations:
function analyticsContract(name: string, create: () => AnalyticsPort) {
describe(`AnalyticsPort contract: ${name}`, () => {
it('does not throw when tracking an event', () => {
const adapter = create();
expect(() => adapter.track({ name: 'test_event' })).not.toThrow();
});
it('accepts optional properties', () => {
const adapter = create();
expect(() =>
adapter.track({ name: 'test_event', properties: { a: 1, b: 'x', c: true } }),
).not.toThrow();
});
});
}
analyticsContract('noop', () => new NoopAnalyticsAdapter());
analyticsContract('vendor', () => new VendorAnalyticsAdapter()); // with the SDK stubbed
Failure simulation
Because vendor behavior is behind one contract, failure cases are easy to model in one place:
const failingPayments: Partial<PaymentService> = {
confirm: () => Promise.reject(new PaymentError('declined', 'The card was declined.')),
};
You can test declines, timeouts, and partial data without depending on a vendor sandbox.
15. Security Considerations
A wrapper does not make an application secure. A poorly written adapter can leak tokens as easily as direct usage.
What isolation can help centralize:
- token handling: one place decides where tokens are attached and stored
- SDK configuration: no duplicated or inconsistent secrets/settings
- logging: consistent policy about what is emitted
- sensitive data filtering: strip PII before it leaves for a third party
- error sanitization: prevent vendor error details from reaching the UI
- security-related integration logic: one place to review and audit
Example: a PII filter in the analytics adapter.
const SENSITIVE_KEYS = new Set(['email', 'phone', 'password', 'token']);
track(event: AnalyticsEvent): void {
const safe = Object.fromEntries(
Object.entries(event.properties ?? {}).filter(([key]) => !SENSITIVE_KEYS.has(key)),
);
thirdPartyAnalytics.track(event.name, safe);
}
Security still depends on correct implementation, review, and testing.
16. A Realistic Migration Scenario
Today the application uses Vendor A. Tomorrow, Vendor A becomes too expensive, introduces breaking changes, or no longer meets requirements.
WITHOUT ABSTRACTION
25 files
↓
Vendor-specific API
↓
Migration
↓
25+ files touched
WITH ABSTRACTION
25 components
↓
Application API
↓
Vendor Adapter
↓
Replace adapter
With a port and adapter, the migration looks like this:
// before
{ provide: ANALYTICS, useClass: VendorAAnalyticsAdapter }
// after
{ provide: ANALYTICS, useClass: VendorBAnalyticsAdapter }
Write VendorBAnalyticsAdapter, run the contract tests, then switch the provider. You can even run both behind a small composite adapter during a gradual rollout.
The honest caveat: actual migration effort depends on how much vendor behavior leaked into the application. Vendor B may not support a capability your port assumed, or your port may have quietly encoded Vendor A's quirks. A good port describes what your app needs, not what Vendor A happens to offer.
17. When NOT to Wrap
Abstraction has a cost: more files, more indirection, and a contract to maintain. Do not automatically wrap:
- Small utility packages (a date formatter used in two places)
- Stable, generic TypeScript utilities (e.g. general-purpose helpers with strong typing)
- Libraries used in one isolated technical area (one module, one owner)
- Packages whose API already represents your desired abstraction (RxJS is a foundational part of Angular, and hiding it behind your own observable clone adds noise)
- Tiny dependencies where replacement cost is negligible
Abstraction should reduce change cost, not create abstraction for abstraction's sake.
A wrapper that merely renames every third-party method adds noise:
// ❌ meaningless pass-through
@Injectable({ providedIn: 'root' })
export class DateUtilService {
format(d: Date, pattern: string) { return vendorDate.format(d, pattern); }
addDays(d: Date, n: number) { return vendorDate.addDays(d, n); }
// ...every vendor method, renamed
}
It isolates nothing: change the vendor's function signature and this file changes, and every caller if the semantics differ. A meaningful boundary isolates vendor decisions and protects the rest of the application from them.
18. Decision Framework and Checklist
Strong candidates for isolation
| Category | Examples |
|---|---|
| Analytics | Google Analytics, Mixpanel, Amplitude, PostHog |
| Logging / Monitoring | Sentry, Application Insights, Datadog |
| Authentication | Keycloak, Auth0, Okta |
| Payments | Stripe, PayPal |
| Maps | Google Maps, Mapbox |
| Charts | ECharts, ApexCharts, Highcharts |
| Editors | Monaco, TipTap, CKEditor |
| File upload / storage SDKs | Cloud storage clients |
| Browser-specific SDKs | Anything attached to window
|
| External APIs with unstable client libraries | Fast-moving vendor SDKs |
These are often expensive to replace or mock.
What to weigh
- How widely the dependency is used
- How much application code depends on its API
- How difficult it would be to replace
- Whether it contains business-critical behavior
- Whether it interacts with browser globals
- Whether it manages infrastructure concerns
- Whether it exposes unstable or vendor-specific APIs
- Whether it needs centralized configuration
- Whether it needs mocking during tests
Checklist
- [ ] Is this dependency business-critical?
- [ ] Is replacement expensive?
- [ ] Is it used in multiple features?
- [ ] Does it expose vendor-specific types?
- [ ] Does it require centralized configuration?
- [ ] Does it interact with browser globals?
- [ ] Does it require complex mocking?
- [ ] Would a vendor change touch many files?
- [ ] Does the application need its own semantic API?
If several answers are yes, consider an application-owned boundary. The guiding rule: isolate dependencies that are expensive to change.
19. Comparison Table
| Pattern | Purpose | When to use | Main benefit | Potential downside |
|---|---|---|---|---|
| Direct dependency | Use the library as-is | Small, stable, isolated, cheap to replace | Zero indirection | Vendor coupling spreads with usage |
| Wrapper | Thin app-owned layer over one library | Repeated usage; want consistent defaults and one import site | Simple, fast seam | Becomes noise if it only renames methods |
| Adapter | Implement an app-owned port using a vendor API | High replacement cost; multiple possible providers | Swap vendors without touching features | Requires good port design; more code |
| Facade | Simplify a workflow across several services | A feature orchestrates multiple capabilities | Clean, feature-facing API | Can grow into a god object |
| InjectionToken | Provide/replace a dependency through DI | Browser globals, SDK instances, config, tests | Testable, environment-aware | Indirection if used for trivial values |
| UI wrapper | App-owned component around vendor UI | Vendor UI is part of your product | Consistent inputs/outputs, a11y, styling | Wrapper API must be maintained as needs grow |
20. Common Mistakes
- Wrapping every npm package. Costs more than it saves. Use the checklist.
- Creating meaningless pass-through services. Renaming methods is not a boundary.
-
Leaking vendor types.
VendorEventin a public signature defeats the abstraction. -
Naming application APIs after vendors.
StripeServicein feature code implies Stripe forever. - Putting business logic inside adapters. Adapters translate; they shouldn't make business decisions.
-
Duplicating vendor configuration. Centralize it in a
provideX()function. -
Creating giant
ThirdPartyServiceclasses. Split by capability (analytics, logging, payments). - Mixing multiple vendors inside one abstraction. One port per capability.
- Ignoring testability. If you can't fake it easily, the seam is in the wrong place.
- Confusing abstraction with unnecessary complexity. Measure by change cost, not by neatness.
21. The 10 Rules
- Don't let infrastructure APIs become business APIs.
- Keep vendor-specific types at the boundary.
- Use application-owned interfaces where replacement cost is high.
- Centralize configuration.
- Centralize error translation.
- Centralize vendor-specific behavior.
- Expose semantic application APIs.
- Keep wrappers small.
- Don't create abstractions without a real boundary.
- Test the application contract independently from the vendor.
22. Conclusion
The goal isn't to wrap everything.
The goal is to isolate dependencies whose replacement, configuration, testing, or failure handling would otherwise spread across the application.
A wrapper isn't automatically good architecture. A wrapper that merely renames every third-party method adds noise. A meaningful boundary isolates vendor decisions and protects the rest of the application from them.
Ask yourself: how many third-party APIs have leaked into your Angular features?
Audit one dependency this week. Create one boundary. Make the next migration smaller.
Third-party libraries are implementation details. Your application's architecture should not become one.
What's one third-party dependency you would isolate today if you had to redesign your application? Share it in the comments.
📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.
Top comments (3)
The distinction between a useful boundary and a pass-through wrapper comes down to whether the application owns the semantics. A PaymentService.createPayment() contract is valuable only if it expresses what the product means by creating a payment—not just renames a vendor call while leaking its statuses, errors, retry behavior, and assumptions elsewhere.
That also makes the port the hard part. If it mirrors Vendor A too closely, switching providers still means changing the contract; if it promises capabilities no provider can reliably deliver, the adapter becomes a pile of awkward compromises. I’d define the smallest contract the application genuinely needs, translate errors and behavior at the edge, and run the same contract tests against each adapter. That’s a real seam—not abstraction for its own sake.
This is the part I'd add a whole section on if I rewrote the piece. "Owns the semantics" is a better test than anything I wrote about pass-through wrappers — it explains why renaming vendor methods fails, instead of just asserting that it does.
The port-design trade-off you describe is the real risk in this whole approach, and I underplayed it. A port too close to Vendor A doesn't isolate anything; a port too ambitious just moves the vendor's mess into your adapters. In practice, I've found the "smallest contract the application genuinely needs" only becomes visible once you've built at least one adapter and tried to imagine a second — trying to design the port abstractly, before you have two real implementations to check it against, is usually where it drifts toward one vendor's shape.
Appreciate the precision here — this is a better articulation of the core idea than my "checklist" framing.
That’s a great way to put it. The contract often becomes clearer after you’ve built a real adapter and can compare it with a plausible second one. Otherwise, it’s easy to mistake Vendor A’s shape for what the application actually needs. “Smallest contract the application genuinely needs” is the test I’d keep coming back to.