SiamCafe.net Blog
Technology

Lit Element Hexagonal Architecture สร้าง Web Components ด้วย Clean Architecture

lit element hexagonal architecture
Lit Element Hexagonal Architecture | SiamCafe Blog
2025-11-22· อ. บอม — SiamCafe.net· 1,373 คำ

Lit Element ????????? Hexagonal Architecture ?????????????????????

Lit Element ???????????? library ????????????????????????????????? Web Components ????????? Google ????????? standard Web Components APIs (Custom Elements, Shadow DOM, HTML Templates) ??????????????? reactive properties ????????? declarative templates ????????????????????????????????? (~5KB gzipped) ???????????????????????? frameworks ???????????? ????????????????????????????????? framework (React, Vue, Angular) ??????????????????????????? standard Web Components

Hexagonal Architecture (Ports and Adapters) ???????????????????????????????????????????????? software ????????? Alistair Cockburn ????????? business logic ?????????????????? infrastructure ?????????????????? Ports (interfaces) ??????????????? contract ????????????????????? domain ????????? outside world ????????? Adapters implement interfaces ????????????????????????????????????????????? infrastructure ???????????? (API, database, UI)

?????????????????? Lit Element ????????? Hexagonal Architecture ??????????????? Web Components ?????? architecture ??????????????? Domain logic ??????????????????????????? UI framework, Testable ??????????????? business logic ?????????????????? UI ?????????, Flexible ????????????????????? data source ???????????????????????? UI, Maintainable ????????? concerns ??????????????????

????????????????????? Project Structure

??????????????????????????? project ????????? Hexagonal Architecture

# === Lit Element Hexagonal Architecture Setup ===

# 1. Create Project
npm create vite@latest lit-hex-app -- --template lit-ts
cd lit-hex-app

# 2. Install Dependencies
npm install lit @lit/reactive-element
npm install -D typescript vitest @vitest/ui
npm install -D @open-wc/testing @web/test-runner

# 3. Project Structure
cat > project-structure.txt << 'EOF'
src/
  # Domain Layer (innermost, no dependencies)
  domain/
    models/
      todo.ts           # Domain entity
      user.ts           # Domain entity
    services/
      todo-service.ts   # Domain service (business logic)
    ports/
      todo-repository.ts  # Port (interface) for persistence
      notification-port.ts # Port for notifications

  # Application Layer (use cases)
  application/
    use-cases/
      create-todo.ts
      complete-todo.ts
      list-todos.ts

  # Adapters Layer (implementations)
  adapters/
    api/
      rest-todo-adapter.ts     # REST API adapter
      graphql-todo-adapter.ts  # GraphQL adapter
    storage/
      localstorage-adapter.ts  # LocalStorage adapter
      indexeddb-adapter.ts     # IndexedDB adapter
    notification/
      toast-adapter.ts         # UI notification

  # UI Layer (Lit Element components)
  ui/
    components/
      todo-app.ts        # Main app component
      todo-list.ts       # Todo list component
      todo-item.ts       # Single todo item
      todo-form.ts       # Add todo form
    styles/
      shared-styles.ts   # Shared CSS

  # Configuration
  config/
    container.ts         # Dependency injection container

  index.ts               # Entry point
EOF

# 4. TypeScript Config
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2021",
    "module": "ESNext",
    "lib": ["ES2021", "DOM", "DOM.Iterable"],
    "moduleResolution": "bundler",
    "strict": true,
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "declaration": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
EOF

echo "Project structure created"

??????????????? Domain Layer ????????? Ports

Domain models, services ????????? port interfaces

// === Domain Layer ===

// File: src/domain/models/todo.ts
export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export function createTodo(title: string): Todo {
  const now = new Date();
  return {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: now,
    updatedAt: now,
  };
}

export function completeTodo(todo: Todo): Todo {
  return { ...todo, completed: true, updatedAt: new Date() };
}

export function isValidTitle(title: string): boolean {
  return title.trim().length >= 1 && title.trim().length <= 200;
}

// File: src/domain/ports/todo-repository.ts
export interface TodoRepository {
  getAll(): Promise<Todo[]>;
  getById(id: string): Promise<Todo | null>;
  save(todo: Todo): Promise<void>;
  delete(id: string): Promise<void>;
  update(todo: Todo): Promise<void>;
}

// File: src/domain/ports/notification-port.ts
export interface NotificationPort {
  success(message: string): void;
  error(message: string): void;
  info(message: string): void;
}

// File: src/domain/services/todo-service.ts
export class TodoService {
  constructor(
    private repository: TodoRepository,
    private notification: NotificationPort
  ) {}

  async addTodo(title: string): Promise<Todo> {
    if (!isValidTitle(title)) {
      this.notification.error('Title must be 1-200 characters');
      throw new Error('Invalid title');
    }

    const todo = createTodo(title);
    await this.repository.save(todo);
    this.notification.success(`Added: `);
    return todo;
  }

  async completeTodo(id: string): Promise<Todo> {
    const todo = await this.repository.getById(id);
    if (!todo) throw new Error('Todo not found');

    const completed = completeTodo(todo);
    await this.repository.update(completed);
    this.notification.info(`Completed: `);
    return completed;
  }

  async getAllTodos(): Promise<Todo[]> {
    return this.repository.getAll();
  }

  async deleteTodo(id: string): Promise<void> {
    await this.repository.delete(id);
    this.notification.info('Todo deleted');
  }
}

Adapters ????????? Infrastructure

Implement ports ???????????? adapters

// === Adapters Layer ===

// File: src/adapters/storage/localstorage-adapter.ts
import { Todo } from '../../domain/models/todo';
import { TodoRepository } from '../../domain/ports/todo-repository';

export class LocalStorageTodoAdapter implements TodoRepository {
  private key = 'todos';

  async getAll(): Promise<Todo[]> {
    const data = localStorage.getItem(this.key);
    if (!data) return [];
    return JSON.parse(data).map((t: any) => ({
      ...t,
      createdAt: new Date(t.createdAt),
      updatedAt: new Date(t.updatedAt),
    }));
  }

  async getById(id: string): Promise<Todo | null> {
    const todos = await this.getAll();
    return todos.find(t => t.id === id) || null;
  }

  async save(todo: Todo): Promise<void> {
    const todos = await this.getAll();
    todos.push(todo);
    localStorage.setItem(this.key, JSON.stringify(todos));
  }

  async delete(id: string): Promise<void> {
    const todos = await this.getAll();
    const filtered = todos.filter(t => t.id !== id);
    localStorage.setItem(this.key, JSON.stringify(filtered));
  }

  async update(todo: Todo): Promise<void> {
    const todos = await this.getAll();
    const index = todos.findIndex(t => t.id === todo.id);
    if (index >= 0) {
      todos[index] = todo;
      localStorage.setItem(this.key, JSON.stringify(todos));
    }
  }
}

// File: src/adapters/api/rest-todo-adapter.ts
import { Todo } from '../../domain/models/todo';
import { TodoRepository } from '../../domain/ports/todo-repository';

export class RestTodoAdapter implements TodoRepository {
  constructor(private baseUrl: string) {}

  async getAll(): Promise<Todo[]> {
    const res = await fetch(`/todos`);
    if (!res.ok) throw new Error('Failed to fetch todos');
    return res.json();
  }

  async getById(id: string): Promise<Todo | null> {
    const res = await fetch(`/todos/`);
    if (res.status === 404) return null;
    return res.json();
  }

  async save(todo: Todo): Promise<void> {
    await fetch(`/todos`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo),
    });
  }

  async delete(id: string): Promise<void> {
    await fetch(`/todos/`, { method: 'DELETE' });
  }

  async update(todo: Todo): Promise<void> {
    await fetch(`/todos/`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo),
    });
  }
}

// File: src/adapters/notification/toast-adapter.ts
import { NotificationPort } from '../../domain/ports/notification-port';

export class ToastNotificationAdapter implements NotificationPort {
  success(message: string): void {
    this.showToast(message, 'success');
  }
  error(message: string): void {
    this.showToast(message, 'error');
  }
  info(message: string): void {
    this.showToast(message, 'info');
  }
  private showToast(message: string, type: string): void {
    const event = new CustomEvent('show-toast', {
      detail: { message, type },
      bubbles: true, composed: true,
    });
    document.dispatchEvent(event);
  }
}

// File: src/config/container.ts
import { TodoService } from '../domain/services/todo-service';
import { LocalStorageTodoAdapter } from '../adapters/storage/localstorage-adapter';
import { ToastNotificationAdapter } from '../adapters/notification/toast-adapter';

// Dependency Injection Container
const repository = new LocalStorageTodoAdapter();
const notification = new ToastNotificationAdapter();
export const todoService = new TodoService(repository, notification);

Web Components ???????????? Lit Element

??????????????? UI components

// === Lit Element UI Components ===

// File: src/ui/components/todo-app.ts
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { Todo } from '../../domain/models/todo';
import { todoService } from '../../config/container';
import './todo-form';
import './todo-list';

@customElement('todo-app')
export class TodoApp extends LitElement {
  static styles = css`
    :host {
      display: block;
      max-width: 600px;
      margin: 0 auto;
      padding: 2rem;
      font-family: system-ui, sans-serif;
    }
    h1 { color: #1a1a1a; text-align: center; }
    .stats {
      display: flex; gap: 1rem; justify-content: center;
      margin: 1rem 0; color: #666;
    }
  `;

  @state() private todos: Todo[] = [];

  async connectedCallback() {
    super.connectedCallback();
    await this.loadTodos();
  }

  private async loadTodos() {
    this.todos = await todoService.getAllTodos();
  }

  private async handleAdd(e: CustomEvent) {
    await todoService.addTodo(e.detail.title);
    await this.loadTodos();
  }

  private async handleComplete(e: CustomEvent) {
    await todoService.completeTodo(e.detail.id);
    await this.loadTodos();
  }

  private async handleDelete(e: CustomEvent) {
    await todoService.deleteTodo(e.detail.id);
    await this.loadTodos();
  }

  render() {
    const active = this.todos.filter(t => !t.completed).length;
    const completed = this.todos.filter(t => t.completed).length;

    return html`
      <h1>Todo App (Hexagonal)</h1>
      <div class="stats">
        <span>Active: </span>
        <span>Completed: </span>
        <span>Total: </span>
      </div>
      <todo-form @add-todo=></todo-form>
      <todo-list
        .todos=
        @complete-todo=
        @delete-todo=
      ></todo-list>
    `;
  }
}

// File: src/ui/components/todo-item.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Todo } from '../../domain/models/todo';

@customElement('todo-item')
export class TodoItem extends LitElement {
  static styles = css`
    :host { display: block; }
    .item {
      display: flex; align-items: center; gap: 0.5rem;
      padding: 0.75rem; border-bottom: 1px solid #eee;
    }
    .completed { text-decoration: line-through; color: #999; }
    button { cursor: pointer; border: none; border-radius: 4px; padding: 0.25rem 0.5rem; }
    .complete-btn { background: #4caf50; color: white; }
    .delete-btn { background: #f44336; color: white; }
  `;

  @property({ type: Object }) todo!: Todo;

  render() {
    return html`
      <div class="item">
        <span class=>
          
        </span>
        >Done</button>
        ` : ''}
        <button class="delete-btn" @click=>Delete</button>
      </div>
    `;
  }

  private _complete() {
    this.dispatchEvent(new CustomEvent('complete-todo', {
      detail: { id: this.todo.id }, bubbles: true, composed: true,
    }));
  }

  private _delete() {
    this.dispatchEvent(new CustomEvent('delete-todo', {
      detail: { id: this.todo.id }, bubbles: true, composed: true,
    }));
  }
}

Testing ????????? Dependency Injection

??????????????? domain logic ?????????????????? UI

#!/usr/bin/env python3
# test_concepts.py ??? Testing Hexagonal Architecture Concepts
import json
import logging
from typing import Dict, List

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test")

class HexagonalTestingGuide:
    def __init__(self):
        pass
    
    def testing_strategy(self):
        return {
            "domain_tests": {
                "description": "??????????????? business logic ?????????????????????????????? UI ???????????? infrastructure",
                "approach": "Unit tests with mock adapters",
                "framework": "Vitest",
                "example": """
// test/domain/todo-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { TodoService } from '../src/domain/services/todo-service';

// Mock adapters (fake implementations of ports)
const mockRepo = {
  getAll: vi.fn().mockResolvedValue([]),
  getById: vi.fn(),
  save: vi.fn().mockResolvedValue(undefined),
  delete: vi.fn(),
  update: vi.fn(),
};
const mockNotification = {
  success: vi.fn(),
  error: vi.fn(),
  info: vi.fn(),
};

describe('TodoService', () => {
  const service = new TodoService(mockRepo, mockNotification);
  
  it('should add a valid todo', async () => {
    const todo = await service.addTodo('Buy milk');
    expect(todo.title).toBe('Buy milk');
    expect(mockRepo.save).toHaveBeenCalled();
    expect(mockNotification.success).toHaveBeenCalled();
  });
  
  it('should reject empty title', async () => {
    await expect(service.addTodo('')).rejects.toThrow();
    expect(mockNotification.error).toHaveBeenCalled();
  });
});
                """,
                "coverage_target": "90%+",
            },
            "adapter_tests": {
                "description": "??????????????? adapter implementations",
                "approach": "Integration tests with real storage",
                "example": "Test LocalStorageAdapter with mock localStorage",
            },
            "component_tests": {
                "description": "??????????????? Lit Element components",
                "approach": "Component tests with @open-wc/testing",
                "framework": "@web/test-runner",
            },
            "e2e_tests": {
                "description": "??????????????? full application flow",
                "approach": "Playwright or Cypress",
                "framework": "Playwright",
            },
        }
    
    def di_patterns(self):
        return {
            "constructor_injection": {
                "description": "????????? dependencies ???????????? constructor (recommended)",
                "example": "new TodoService(repository, notification)",
                "pros": "?????????????????? testable ????????????",
            },
            "service_locator": {
                "description": "????????? container ???????????? services",
                "example": "container.get('todoService')",
                "pros": "Flexible ????????? hidden dependencies",
            },
            "context_protocol": {
                "description": "Lit Context Protocol ?????????????????? Web Components",
                "example": "@consume({ context: todoServiceContext })",
                "pros": "Framework-native ?????????????????? Lit",
            },
        }

guide = HexagonalTestingGuide()
strategy = guide.testing_strategy()
print("Testing Strategy:")
for layer, info in strategy.items():
    print(f"\n  {layer}: {info['description']}")
    print(f"    Approach: {info['approach']}")

di = guide.di_patterns()
print("\nDI Patterns:")
for name, info in di.items():
    print(f"  {name}: {info['description']}")

FAQ ??????????????????????????????????????????

Q: Lit Element ????????? React ???????????????????????????????????????????

A: Lit Element ????????? Web Components standard ????????? browser ????????????????????????????????? framework ????????????????????????????????? (~5KB) ????????????????????? virtual DOM render ????????????????????? ??????????????? design systems, micro-frontends, embeddable widgets React ????????? virtual DOM ecosystem ?????????????????????????????? community ??????????????????????????? ???????????? build step ???????????? ???????????????????????????????????? (~40KB) ??????????????? SPA, complex applications ??????????????? Lit ??????????????? ??????????????? reusable components ?????????????????????????????? frameworks, ?????????????????????????????????????????????, ????????????????????? standard Web Components ??????????????? React ??????????????? ??????????????? full SPA, ????????????????????? ecosystem ????????????, ?????????????????????????????? React

Q: Hexagonal Architecture ???????????????????????????????????? frontend ??????????

A: ????????????????????????????????????????????? project ???????????? ????????????????????????????????????????????????????????????????????? project ????????? ????????????????????????????????? data source ???????????? (REST API ??? GraphQL ??? local storage), ?????? complex business logic ????????????????????? test, ???????????? reuse business logic ???????????? platforms (web, mobile, desktop), ????????????????????? ???????????????????????? ownership ?????????????????? ?????????????????? small/medium projects ????????? simple layered architecture (components ??? services ??? API) ????????????????????? ??????????????? complexity ??????????????????????????? refactor ???????????? hexagonal

Q: Web Components ?????????????????????????????? React/Vue ???????????????????????????????

A: ????????????????????? Web Components ???????????? browser standard ????????????????????????????????????????????????????????? DOM ?????? React ????????? Web Components ?????????????????? ???????????????????????????????????? event handling (React ????????? synthetic events ???????????? use ref + addEventListener ?????????????????? custom events) ?????? Vue ????????????????????????????????? Vue ?????????????????? Web Components native ?????? Angular ????????? CUSTOM_ELEMENTS_SCHEMA Lit Element ??????????????? Web Components ????????? interop ???????????????????????????????????????????????????????????? thin wrapper ?????? standard APIs

Q: Shadow DOM ????????????????????????????????????????

A: Shadow DOM ????????? style encapsulation ??????????????? CSS ????????? leak ???????????? components ??????????????????????????????????????? Global CSS (fonts, CSS variables) ????????????????????? CSS custom properties ???????????????????????????, Form participation Shadow DOM elements ????????? participate ????????? form ???????????? (????????????????????? ElementInternals), Accessibility screen readers ??????????????????????????? handle Shadow DOM ??????????????????????????????, SEO search engines ?????????????????????????????? Shadow DOM content ??????????????????, Styling ????????? outside ???????????? expose CSS custom properties ???????????? parts Lit ????????? option ????????? Shadow DOM ????????? (createRenderRoot) ??????????????????????????????????????? encapsulation ?????????????????? design systems Shadow DOM ??????????????? ?????????????????? content-heavy pages ?????????????????????????????????

📖 บทความที่เกี่ยวข้อง

BGP Routing Advanced Hexagonal Architectureอ่านบทความ → Lit Element SSL TLS Certificateอ่านบทความ → Lit Element Interview Preparationอ่านบทความ → Lit Element Stream Processingอ่านบทความ → Lit Element Learning Path Roadmapอ่านบทความ →

📚 ดูบทความทั้งหมด →