Browser extensions often begin as simple utilities—a few listeners in a background script, a small content script, and maybe a popup. But as the extension grows to include multiple runtime contexts, more messaging, state, and browser-specific behavior, the codebase can quickly become difficult to reason about.
That problem gets sharper in modern extensions because the application is split across isolated environments:
- Background
- Content
- Managed UI such as popup or DevTools
These are not just folders. They are separate runtime contexts with different capabilities and lifecycles.
That’s where architecture starts to matter.
HexaJS is a structured, decorator-driven framework for browser extensions. It brings together Dependency Injection, tokens, controllers, handlers, and an AOT build pipeline so extension code stays modular, predictable, and scalable.
Why structure matters in browser extensions
In a typical extension, complexity grows in a few familiar ways:
- message names become scattered strings
- browser APIs leak into too many places
- content and background responsibilities get mixed together
- configuration is hidden in globals or utility modules
- testing gets harder as dependencies become implicit
HexaJS addresses this by treating runtime boundaries as first-class architecture.
Its docs describe a model where Background, Content, and UI each run with their own generated bootstrap and isolated DI container. That means a class valid in one context is not automatically valid in another.
Dependency Injection with context awareness
HexaJS uses a lightweight decorator-based DI model.
A service can be declared with an explicit runtime context:
import { Injectable, HexaContext } from '@hexajs-dev/common';
@Injectable({ context: HexaContext.Background })
export class TabQueryService {
getActiveIdFromTabs(tabs: Array<{ id?: number }>): number {
return tabs[0]?.id ?? -1;
}
}
This is important because browser extensions are inherently multi-context. A background service should not accidentally be injected into content or UI code if it depends on background-only capabilities.
HexaJS validates those boundaries during its AOT build step.
So DI here is not just about cleaner constructors—it is also about enforcing architectural correctness before runtime.
Tokens make configuration explicit
Not every dependency is a class. Some are values: platform, build mode, feature flags, or API URLs.
HexaJS supports token-based injection for those cases.
Built-in system tokens include values like HEXA_PLATFORM and HEXA_BUILD_MODE, and custom tokens can be created with createToken(...).
import { createToken, Inject, Injectable, HexaContext } from '@hexajs-dev/common';
export const API_BASE_URL = createToken(
'API_BASE_URL',
'https://api.example.com',
HexaContext.Background
);
@Injectable({ context: HexaContext.Background })
export class ApiConfigService {
constructor(@Inject(API_BASE_URL) private apiBaseUrl: string) {}
getBaseUrl(): string {
return this.apiBaseUrl;
}
}
This keeps configuration explicit and testable.
Instead of pulling values from scattered globals, the dependency is visible in the constructor and can be overridden through configuration layers in the HexaJS build setup.
Background communication with Controllers
In HexaJS, background-side message endpoints are modeled with Controllers and Actions.
import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort } from '@hexajs-dev/ports';
@Controller({ namespace: 'tabs' })
export class TabsController {
constructor(private tabsPort: TabsPort) {}
@Action('active')
async activeTab(): Promise<{ tabId: number }> {
const tabs = await this.tabsPort.queryTabs({ active: true, currentWindow: true });
return { tabId: tabs[0]?.id ?? -1 };
}
}
This is one of the biggest corrections from the original draft:
- it is
@Controller({ namespace: '...' }), not@Controller('...') - it is
@Action('...'), not@Message('...')
The resulting route is effectively namespaced as:
tabs:active
That makes background communication more structured than manually wiring chrome.runtime.onMessage handlers or large switch statements.
HexaJS also recommends using ports instead of direct chrome.* or browser.* API calls, which keeps controller logic focused on orchestration rather than browser-specific plumbing.
Content behavior with Handlers
On the content side, HexaJS uses Handlers and Handle decorators.
import { Handler, Handle } from '@hexajs-dev/core';
import { LoggerService } from '../services/logger.service';
import { MyContentEntry } from './content';
@Handler({ namespace: 'tabs', Contents: [MyContentEntry] })
export class TabsHandler {
constructor(private logger: LoggerService) {}
@Handle('active-id')
onActiveId(payload: { tabId: number }): { ok: boolean } {
this.logger.log('Active tab id from background:', payload.tabId);
return { ok: true };
}
}
This is another important fix from the original version:
- it is
@Handler({ namespace, Contents }), not@Handler('page') - it is
@Handle('...'), not@Action('...')inside content handlers
The Contents option is especially useful because content scripts are often more fragmented than background logic. A single extension may have multiple content entry classes for different sites or page families, and HexaJS lets handlers bind intentionally to those entries.
That makes large multi-site extensions much easier to organize.
Clean transport across contexts
A common problem in browser extension code is that transport logic ends up mixed into business logic. Raw chrome.runtime.sendMessage(...) calls get scattered everywhere, and eventually every feature carries its own messaging details.
HexaJS pushes toward structured routing instead.
For UI surfaces, the docs show using HexaUIClient via DI:
import { inject } from '@hexajs-dev/common';
import { HexaUIClient } from '@hexajs-dev/ui';
const uiClient = inject(HexaUIClient);
const result = await uiClient.sendMessage('tabInfo:current', {});
console.log('Active tab metadata:', result);
This is another correction from the original example: I could not verify HexaContentClientService in the repo/docs you pointed me to, so I would avoid naming it in the post unless you specifically want to introduce it separately and confirm it exists in your public API.
What is clearly documented is the idea that UI and content layers communicate through structured routes, while browser-specific behavior is delegated through ports and handled in the appropriate context.
Why this scales better
This kind of structure helps browser extensions scale in a few practical ways:
1. Better separation of concerns
Controllers stay background-focused. Handlers stay content-focused. Services hold reusable logic. Tokens represent values and configuration.
2. Easier testing
DI makes dependencies explicit, so services and ports are easier to mock.
3. Safer refactoring
When route definitions and context boundaries are structured, it becomes much easier to move code without accidentally crossing runtime boundaries.
4. Reduced browser-specific coupling
Ports isolate platform details so the rest of the application can focus on behavior.
5. Long-term maintainability
The framework is designed less for the fastest possible hello-world and more for the codebase you can still understand months later.
Final thought
Browser extensions are no longer tiny scripts.
They are multi-context applications with isolated runtimes, lifecycle boundaries, and transport concerns. If the architecture does not reflect that reality, complexity spreads fast.
HexaJS approaches that problem with a structured model:
- Dependency Injection for explicit dependencies
- Tokens for values and configuration
- Controllers for background routes
- Handlers for content-side behavior
- AOT analysis for context-aware validation and generated wiring
The result is a browser extension codebase that is easier to reason about, easier to test, and easier to scale.
If you’re building modern browser extensions and want a more structured approach, check out HexaJS.
Quick reference snippets
Background service
import { Injectable, HexaContext } from '@hexajs-dev/common';
@Injectable({ context: HexaContext.Background })
export class TabQueryService {
getActiveIdFromTabs(tabs: Array<{ id?: number }>): number {
return tabs[0]?.id ?? -1;
}
}
Token injection
import { Inject, Injectable, HEXA_PLATFORM } from '@hexajs-dev/common';
@Injectable()
export class PlatformLabelService {
constructor(@Inject(HEXA_PLATFORM) private platform: string) {}
getLabel(): string {
return `running on ${this.platform}`;
}
}
Background controller
import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort } from '@hexajs-dev/ports';
@Controller({ namespace: 'tabs' })
export class TabsController {
constructor(private tabsPort: TabsPort) {}
@Action('active')
async activeTab(): Promise<{ tabId: number }> {
const tabs = await this.tabsPort.queryTabs({ active: true, currentWindow: true });
return { tabId: tabs[0]?.id ?? -1 };
}
}
Content handler
import { Handler, Handle } from '@hexajs-dev/core';
import { MyContentEntry } from './content';
@Handler({ namespace: 'tabs', Contents: [MyContentEntry] })
export class TabsHandler {
@Handle('active-id')
onActiveId(payload: { tabId: number }): { ok: boolean } {
return { ok: true };
}
}

























