Clean Architecture for Chrome Extensions: A Maintainable Design Guide

18 min read

Clean Architecture for Chrome Extensions: A Maintainable Design Guide

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.

No previous article
No next article