Clean Architecture for Chrome Extensions: A Maintainable Design Guide
As Chrome extensions grow in complexity, developers often encounter a common problem: the codebase becomes increasingly difficult to maintain, test, and extend. What starts as a simple popup script evolves into a tangled web of dependencies between content scripts, background service workers, and popup UI. This is where clean architecture for Chrome extensions becomes essential.
Clean architecture, also known as hexagonal architecture or ports and adapters architecture, provides a systematic approach to organizing code that separates concerns, maximizes testability, and keeps your extension maintainable as it scales. In this guide, we will explore how to apply these proven software design principles specifically to Chrome extension development.
Understanding Clean Architecture Fundamentals
Clean architecture is not a specific framework or library—it is a set of guidelines for structuring your code to achieve separation of concerns. The core principle is that business logic should be independent of external concerns like user interfaces, databases, and frameworks.
At its heart, clean architecture defines several concentric layers, each with specific responsibilities:
The Domain Layer (Core Business Logic)
The innermost layer contains your business rules and entities. This layer has no dependencies on external frameworks or technologies. In a Chrome extension context, this might include:
- Data models for your extension’s entities
- Business rules for processing information
- Domain services that encapsulate core functionality
The Application Layer (Use Cases)
This layer contains application-specific business rules. It orchestrates the flow of data between the domain layer and external entities. Use cases here represent specific actions your extension can perform, such as “SaveBookmark,” “AnalyzePage,” or “SyncData.”
The Infrastructure Layer (External Interfaces)
This layer implements interfaces defined in the application layer. It contains code that interacts with external systems:
- Chrome APIs (storage, tabs, messaging)
- External APIs and services
- Database implementations
- UI frameworks
The Presentation Layer (UI)
The outermost layer handles everything related to the user interface—popup pages, options pages, content script UIs, and DevTools panels.
The key insight of clean architecture is that dependencies should only point inward. Inner layers know nothing about outer layers, making it possible to change implementations in outer layers without affecting your core business logic.
Why Chrome Extensions Need Clean Architecture
Chrome extensions present unique architectural challenges that make clean architecture particularly valuable:
Multiple Entry Points
Unlike traditional web applications with a single entry point, Chrome extensions have multiple contexts: popup scripts, background service workers, content scripts, options pages, and DevTools panels. Each runs in its own JavaScript environment, creating complexity in sharing code and state.
Manifest V3 Constraints
The transition to Manifest V3 introduced significant architectural changes. Service workers replaced background pages, declarative Net Request replaced webRequest, and there are stricter limitations on executing code in content scripts. These constraints make flexible architecture even more important.
Lifecycle Management
Extensions must handle various lifecycle events: installation, updates, enable/disable, and browser restarts. Service workers have their own lifecycle with termination and wake-up. Clean architecture helps isolate this complexity.
Testing Challenges
Testing Chrome extensions is inherently complex due to the browser environment. Clean architecture’s emphasis on separating business logic from Chrome APIs makes it significantly easier to write unit tests that don’t require a full browser environment.
Implementing Clean Architecture in Chrome Extensions
Now let’s explore practical implementation strategies for applying clean architecture to your Chrome extension projects.
Project Structure
A well-organized project structure is the foundation of clean architecture. Here’s a recommended layout:
my-extension/
├── src/
│ ├── domain/ # Core business logic
│ │ ├── entities/ # Data models
│ │ ├── services/ # Domain services
│ │ └── interfaces/ # Port definitions
│ ├── application/ # Use cases
│ │ └── usecases/ # Application use cases
│ ├── infrastructure/ # External adapters
│ │ ├── chrome/ # Chrome API adapters
│ │ ├── storage/ # Storage implementations
│ │ └── api/ # External API clients
│ └── presentation/ # UI components
│ ├── popup/ # Popup script
│ ├── background/ # Service worker
│ ├── content/ # Content scripts
│ └── options/ # Options page
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
└── manifest.json
Defining Domain Entities
Start by defining your core domain entities independent of any framework:
// src/domain/entities/Bookmark.js
export class Bookmark {
constructor(id, url, title, createdAt, tags = []) {
this.id = id;
this.url = url;
this.title = title;
this.createdAt = createdAt;
this.tags = tags;
}
hasTag(tag) {
return this.tags.includes(tag);
}
matchesSearch(query) {
const lowerQuery = query.toLowerCase();
return this.title.toLowerCase().includes(lowerQuery) ||
this.url.toLowerCase().includes(lowerQuery);
}
}
Creating Port Interfaces
Define interfaces (ports) that your application layer will use. These ports abstract away the implementation details:
// src/domain/interfaces/BookmarkRepository.js
export class BookmarkRepositoryPort {
async save(bookmark) {
throw new Error('Method not implemented');
}
async findById(id) {
throw new Error('Method not implemented');
}
async findAll() {
throw new Error('Method not implemented');
}
async delete(id) {
throw new Error('Method not implemented');
}
async search(query) {
throw new Error('Method not implemented');
}
}
Implementing Infrastructure Adapters
Now implement the actual Chrome-specific adapters that conform to your port interfaces:
// src/infrastructure/chrome/ChromeBookmarkRepository.js
import { BookmarkRepositoryPort } from '../../domain/interfaces/BookmarkRepository.js';
import { Bookmark } from '../../domain/entities/Bookmark.js';
export class ChromeBookmarkRepository extends BookmarkRepositoryPort {
async save(bookmark) {
const storage = await chrome.storage.local.get('bookmarks');
const bookmarks = storage.bookmarks || [];
const existingIndex = bookmarks.findIndex(b => b.id === bookmark.id);
if (existingIndex >= 0) {
bookmarks[existingIndex] = bookmark;
} else {
bookmarks.push(bookmark);
}
await chrome.storage.local.set({ bookmarks });
return bookmark;
}
async findById(id) {
const storage = await chrome.storage.local.get('bookmarks');
const bookmarks = storage.bookmarks || [];
const data = bookmarks.find(b => b.id === id);
if (!data) return null;
return new Bookmark(data.id, data.url, data.title, data.createdAt, data.tags);
}
async findAll() {
const storage = await chrome.storage.local.get('bookmarks');
const bookmarks = (storage.bookmarks || []).map(
data => new Bookmark(data.id, data.url, data.title, data.createdAt, data.tags)
);
return bookmarks;
}
async delete(id) {
const storage = await chrome.storage.local.get('bookmarks');
const bookmarks = storage.bookmarks || [];
const filtered = bookmarks.filter(b => b.id !== id);
await chrome.storage.local.set({ bookmarks: filtered });
}
async search(query) {
const all = await this.findAll();
return all.filter(bookmark => bookmark.matchesSearch(query));
}
}
Building Application Use Cases
Create use cases that orchestrate your domain logic:
// src/application/usecases/CreateBookmarkUseCase.js
import { Bookmark } from '../../domain/entities/Bookmark.js';
export class CreateBookmarkUseCase {
constructor(bookmarkRepository) {
this.bookmarkRepository = bookmarkRepository;
}
async execute(url, title, tags = []) {
// Validate input
if (!url || !this.isValidUrl(url)) {
throw new Error('Invalid URL provided');
}
if (!title || title.trim().length === 0) {
throw new Error('Title is required');
}
// Create domain entity
const bookmark = new Bookmark(
this.generateId(),
url,
title.trim(),
new Date(),
tags
);
// Persist through repository
return await this.bookmarkRepository.save(bookmark);
}
isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
generateId() {
return `bm_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
Dependency Injection in the Presentation Layer
Finally, wire everything together in your presentation layer using dependency injection:
// src/presentation/popup/index.js
import { ChromeBookmarkRepository } from '../../infrastructure/chrome/ChromeBookmarkRepository.js';
import { CreateBookmarkUseCase } from '../../application/usecases/CreateBookmarkUseCase.js';
import { GetBookmarksUseCase } from '../../application/usecases/GetBookmarksUseCase.js';
// Dependency injection container
const bookmarkRepository = new ChromeBookmarkRepository();
const createBookmarkUseCase = new CreateBookmarkUseCase(bookmarkRepository);
const getBookmarksUseCase = new GetBookmarksUseCase(bookmarkRepository);
// UI Event Handlers
document.getElementById('saveBtn').addEventListener('click', async () => {
const url = document.getElementById('urlInput').value;
const title = document.getElementById('titleInput').value;
const tags = document.getElementById('tagsInput').value.split(',').map(t => t.trim());
try {
await createBookmarkUseCase.execute(url, title, tags);
showSuccess('Bookmark saved!');
refreshBookmarksList();
} catch (error) {
showError(error.message);
}
});
async function refreshBookmarksList() {
const bookmarks = await getBookmarksUseCase.execute();
renderBookmarks(bookmarks);
}
Communication Between Extension Parts
One of the most challenging aspects of Chrome extension development is managing communication between different contexts. Clean architecture provides guidance here as well.
Message Broadcasting
Implement a message bus pattern for decoupled communication:
// src/infrastructure/chrome/MessageBus.js
export class MessageBus {
constructor() {
this.listeners = new Map();
}
subscribe(channel, callback) {
if (!this.listeners.has(channel)) {
this.listeners.set(channel, []);
}
this.listeners.get(channel).push(callback);
// Return unsubscribe function
return () => {
const callbacks = this.listeners.get(channel);
const index = callbacks.indexOf(callback);
if (index > -1) callbacks.splice(index, 1);
};
}
publish(channel, data) {
const callbacks = this.listeners.get(channel) || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in message handler for ${channel}:`, error);
}
});
}
}
// Use in service worker
const messageBus = new MessageBus();
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
messageBus.publish(message.type, message.payload);
return true;
});
State Synchronization
For extensions that need to share state between contexts, implement a proper synchronization mechanism:
// src/infrastructure/chrome/StateManager.js
export class StateManager {
constructor(storageKey = 'appState') {
this.storageKey = storageKey;
this.listeners = new Map();
}
async getState() {
const result = await chrome.storage.local.get(this.storageKey);
return result[this.storageKey] || {};
}
async setState(newState) {
const currentState = await this.getState();
const mergedState = { ...currentState, ...newState };
await chrome.storage.local.set({ [this.storageKey]: mergedState });
this.notifyListeners(mergedState);
}
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
return () => {
const callbacks = this.listeners.get(key);
const index = callbacks.indexOf(callback);
if (index > -1) callbacks.splice(index, 1);
};
}
notifyListeners(state) {
Object.keys(state).forEach(key => {
const callbacks = this.listeners.get(key) || [];
callbacks.forEach(cb => cb(state[key]));
});
}
}
Testing Clean Architecture Code
One of the greatest benefits of clean architecture is testability. Because your domain logic has no dependencies on Chrome APIs, you can test it in isolation:
// tests/unit/CreateBookmarkUseCase.test.js
import { CreateBookmarkUseCase } from '../../src/application/usecases/CreateBookmarkUseCase.js';
// Mock repository
class MockBookmarkRepository {
constructor() {
this.savedBookmarks = [];
}
async save(bookmark) {
this.savedBookmarks.push(bookmark);
return bookmark;
}
async findAll() {
return this.savedBookmarks;
}
}
describe('CreateBookmarkUseCase', () => {
let useCase;
let mockRepository;
beforeEach(() => {
mockRepository = new MockBookmarkRepository();
useCase = new CreateBookmarkUseCase(mockRepository);
});
it('should create a bookmark with valid data', async () => {
const bookmark = await useCase.execute(
'https://example.com',
'Example Site',
['test']
);
expect(bookmark.url).toBe('https://example.com');
expect(bookmark.title).toBe('Example Site');
expect(bookmark.tags).toContain('test');
});
it('should throw error for invalid URL', async () => {
await expect(
useCase.execute('not-a-url', 'Title')
).rejects.toThrow('Invalid URL');
});
it('should throw error for empty title', async () => {
await expect(
useCase.execute('https://example.com', '')
).rejects.toThrow('Title is required');
});
});
This test runs without any Chrome APIs, making it fast and reliable. You can run these tests in any JavaScript environment—Node.js, Jest, or Vitest.
Best Practices and Common Pitfalls
As you implement clean architecture in your Chrome extension projects, keep these best practices in mind:
Do: Start Simple
Don’t over-engineer from the start. Begin with a simple structure and introduce more layers as complexity grows. A small extension doesn’t need the full clean architecture treatment.
Do: Use Dependency Injection
Inject your dependencies rather than creating them inside your use cases. This makes testing easier and allows you to swap implementations.
Don’t: Mix Business Logic with Chrome APIs
Keep your domain logic pure. If you find yourself calling chrome.storage or chrome.tabs inside your use cases, that’s a sign something needs to be refactored.
Do: Define Clear Boundaries
Be intentional about what lives in each layer. Document the responsibilities of each module and enforce boundaries through code reviews.
Don’t: Create God Objects
Avoid creating massive service objects that do everything. Follow the single responsibility principle—each class or function should do one thing well.
Do: Use TypeScript
TypeScript’s type system helps enforce architectural boundaries. Define interfaces for your ports and use type annotations throughout your code.
Conclusion
Clean architecture provides a proven framework for building maintainable, testable Chrome extensions. By separating your business logic from infrastructure concerns, you create code that is easier to understand, test, and extend.
The initial investment in setting up clean architecture pays dividends as your extension grows. You’ll be able to add new features without fear of breaking existing functionality, swap out storage implementations without touching business logic, and write comprehensive tests that run quickly and reliably.
Start with the basics: define your domain entities, create clear interfaces, and implement adapters for Chrome APIs. As your extension evolves, these architectural patterns will help you maintain control over complexity and build extensions that stand the test of time.
Remember, the goal is not to follow architecture patterns blindly, but to create code that serves your users effectively. Clean architecture is a tool to achieve that goal—one that has proven its value in countless production applications across the software industry.