Skip to main content
โšก Calmops

Architectural Patterns in JavaScript

Architectural Patterns in JavaScript

Architectural patterns provide high-level structure for applications. This article covers MVC, MVVM, MVP, Flux, and Clean Architecture patterns.

Introduction

Architectural patterns provide:

  • Application structure
  • Separation of concerns
  • Scalability
  • Maintainability
  • Testability

Understanding these patterns helps you:

  • Design scalable systems
  • Organize code effectively
  • Manage complexity
  • Improve collaboration
  • Build sustainable applications

Model-View-Controller (MVC)

Basic MVC

// โœ… Good: Basic MVC pattern
class Model {
  constructor() {
    this.data = {};
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  notify() {
    this.observers.forEach(observer => observer.update());
  }

  setData(key, value) {
    this.data[key] = value;
    this.notify();
  }

  getData(key) {
    return this.data[key];
  }
}

class View {
  constructor(model) {
    this.model = model;
    this.model.subscribe(this);
  }

  update() {
    this.render();
  }

  render() {
    console.log('Rendering view with data:', this.model.data);
  }
}

class Controller {
  constructor(model, view) {
    this.model = model;
    this.view = view;
  }

  handleUserInput(key, value) {
    this.model.setData(key, value);
  }
}

// Usage
const model = new Model();
const view = new View(model);
const controller = new Controller(model, view);

controller.handleUserInput('name', 'John');
// Rendering view with data: { name: 'John' }

MVC with Multiple Views

// โœ… Good: MVC with multiple views
class UserModel {
  constructor() {
    this.users = [];
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  notify() {
    this.observers.forEach(observer => observer.update());
  }

  addUser(user) {
    this.users.push(user);
    this.notify();
  }

  getUsers() {
    return this.users;
  }
}

class ListView {
  constructor(model) {
    this.model = model;
    this.model.subscribe(this);
  }

  update() {
    console.log('List View:', this.model.getUsers());
  }
}

class TableView {
  constructor(model) {
    this.model = model;
    this.model.subscribe(this);
  }

  update() {
    console.log('Table View:', this.model.getUsers());
  }
}

class UserController {
  constructor(model) {
    this.model = model;
  }

  addUser(user) {
    this.model.addUser(user);
  }
}

// Usage
const model = new UserModel();
const listView = new ListView(model);
const tableView = new TableView(model);
const controller = new UserController(model);

controller.addUser({ name: 'John' });
// List View: [{ name: 'John' }]
// Table View: [{ name: 'John' }]

Model-View-ViewModel (MVVM)

// โœ… Good: MVVM pattern
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserViewModel {
  constructor(model) {
    this.model = model;
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  notify() {
    this.observers.forEach(observer => observer.update());
  }

  get displayName() {
    return `${this.model.name} (${this.model.email})`;
  }

  set displayName(value) {
    const [name, email] = value.split(' (');
    this.model.name = name;
    this.model.email = email.replace(')', '');
    this.notify();
  }

  get name() {
    return this.model.name;
  }

  set name(value) {
    this.model.name = value;
    this.notify();
  }

  get email() {
    return this.model.email;
  }

  set email(value) {
    this.model.email = value;
    this.notify();
  }
}

class UserView {
  constructor(viewModel) {
    this.viewModel = viewModel;
    this.viewModel.subscribe(this);
  }

  update() {
    this.render();
  }

  render() {
    console.log(`Name: ${this.viewModel.name}`);
    console.log(`Email: ${this.viewModel.email}`);
    console.log(`Display: ${this.viewModel.displayName}`);
  }
}

// Usage
const user = new User('John', '[email protected]');
const viewModel = new UserViewModel(user);
const view = new UserView(viewModel);

viewModel.name = 'Jane';
// Name: Jane
// Email: [email protected]
// Display: Jane ([email protected])

Model-View-Presenter (MVP)

// โœ… Good: MVP pattern
class UserModel {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  getName() {
    return this.name;
  }

  getEmail() {
    return this.email;
  }
}

class UserPresenter {
  constructor(model, view) {
    this.model = model;
    this.view = view;
  }

  onViewLoaded() {
    const name = this.model.getName();
    const email = this.model.getEmail();
    this.view.displayUser(name, email);
  }

  onNameChanged(name) {
    this.model.name = name;
    this.view.displayUser(this.model.getName(), this.model.getEmail());
  }
}

class UserView {
  constructor(presenter) {
    this.presenter = presenter;
  }

  displayUser(name, email) {
    console.log(`User: ${name} (${email})`);
  }

  onNameInputChanged(name) {
    this.presenter.onNameChanged(name);
  }
}

// Usage
const model = new UserModel('John', '[email protected]');
const view = new UserView(null);
const presenter = new UserPresenter(model, view);
view.presenter = presenter;

presenter.onViewLoaded();
// User: John ([email protected])

view.onNameInputChanged('Jane');
// User: Jane ([email protected])

Flux Architecture

// โœ… Good: Flux architecture
class Store {
  constructor() {
    this.state = {};
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  notify() {
    this.observers.forEach(observer => observer.update());
  }

  getState() {
    return this.state;
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.notify();
  }
}

class Dispatcher {
  constructor(store) {
    this.store = store;
  }

  dispatch(action) {
    switch (action.type) {
      case 'ADD_USER':
        this.store.setState({
          users: [...(this.store.getState().users || []), action.payload]
        });
        break;
      case 'REMOVE_USER':
        this.store.setState({
          users: this.store.getState().users.filter(u => u.id !== action.payload)
        });
        break;
    }
  }
}

class View {
  constructor(store, dispatcher) {
    this.store = store;
    this.dispatcher = dispatcher;
    this.store.subscribe(this);
  }

  update() {
    this.render();
  }

  render() {
    console.log('Users:', this.store.getState().users);
  }

  addUser(user) {
    this.dispatcher.dispatch({
      type: 'ADD_USER',
      payload: user
    });
  }
}

// Usage
const store = new Store();
const dispatcher = new Dispatcher(store);
const view = new View(store, dispatcher);

view.addUser({ id: 1, name: 'John' });
// Users: [{ id: 1, name: 'John' }]

view.addUser({ id: 2, name: 'Jane' });
// Users: [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]

Clean Architecture

Layered Architecture

// โœ… Good: Clean architecture with layers

// Domain Layer (Business Logic)
class User {
  constructor(id, name, email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  isValid() {
    return this.name && this.email.includes('@');
  }
}

// Application Layer (Use Cases)
class CreateUserUseCase {
  constructor(repository) {
    this.repository = repository;
  }

  execute(name, email) {
    const user = new User(null, name, email);

    if (!user.isValid()) {
      throw new Error('Invalid user data');
    }

    return this.repository.save(user);
  }
}

// Infrastructure Layer (Data Access)
class UserRepository {
  constructor() {
    this.users = [];
  }

  save(user) {
    user.id = this.users.length + 1;
    this.users.push(user);
    return user;
  }

  findById(id) {
    return this.users.find(u => u.id === id);
  }
}

// Presentation Layer (Controllers)
class UserController {
  constructor(createUserUseCase) {
    this.createUserUseCase = createUserUseCase;
  }

  createUser(name, email) {
    try {
      const user = this.createUserUseCase.execute(name, email);
      return { success: true, user };
    } catch (error) {
      return { success: false, error: error.message };
    }
  }
}

// Usage
const repository = new UserRepository();
const useCase = new CreateUserUseCase(repository);
const controller = new UserController(useCase);

const result = controller.createUser('John', '[email protected]');
console.log(result);
// { success: true, user: User { id: 1, name: 'John', email: '[email protected]' } }

Dependency Inversion

// โœ… Good: Clean architecture with dependency inversion
class UserService {
  constructor(repository, emailService, logger) {
    this.repository = repository;
    this.emailService = emailService;
    this.logger = logger;
  }

  createUser(userData) {
    this.logger.log(`Creating user: ${userData.name}`);

    const user = this.repository.save(userData);

    this.emailService.sendWelcomeEmail(user.email);

    this.logger.log(`User created: ${user.id}`);

    return user;
  }
}

// Interfaces (contracts)
class IRepository {
  save(data) { throw new Error('Not implemented'); }
}

class IEmailService {
  sendWelcomeEmail(email) { throw new Error('Not implemented'); }
}

class ILogger {
  log(message) { throw new Error('Not implemented'); }
}

// Implementations
class UserRepository extends IRepository {
  save(data) {
    console.log('Saving to database');
    return { id: 1, ...data };
  }
}

class EmailService extends IEmailService {
  sendWelcomeEmail(email) {
    console.log(`Sending welcome email to ${email}`);
  }
}

class Logger extends ILogger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

// Usage
const repository = new UserRepository();
const emailService = new EmailService();
const logger = new Logger();

const service = new UserService(repository, emailService, logger);
service.createUser({ name: 'John', email: '[email protected]' });

Practical Examples

Todo Application

// โœ… Good: Todo app with MVC
class TodoModel {
  constructor() {
    this.todos = [];
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  notify() {
    this.observers.forEach(observer => observer.update());
  }

  addTodo(text) {
    this.todos.push({ id: Date.now(), text, completed: false });
    this.notify();
  }

  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      this.notify();
    }
  }

  getTodos() {
    return this.todos;
  }
}

class TodoView {
  constructor(model) {
    this.model = model;
    this.model.subscribe(this);
  }

  update() {
    this.render();
  }

  render() {
    console.log('Todos:');
    this.model.getTodos().forEach(todo => {
      const status = todo.completed ? 'โœ“' : 'โ—‹';
      console.log(`  ${status} ${todo.text}`);
    });
  }
}

class TodoController {
  constructor(model) {
    this.model = model;
  }

  addTodo(text) {
    this.model.addTodo(text);
  }

  toggleTodo(id) {
    this.model.toggleTodo(id);
  }
}

// Usage
const model = new TodoModel();
const view = new TodoView(model);
const controller = new TodoController(model);

controller.addTodo('Learn JavaScript');
controller.addTodo('Build an app');
controller.toggleTodo(model.todos[0].id);

Best Practices

  1. Separate concerns:

    // โœ… Good
    class Model { }
    class View { }
    class Controller { }
    
    // โŒ Bad
    class Everything { }
    
  2. Use dependency injection:

    // โœ… Good
    class Service {
      constructor(repository) {
        this.repository = repository;
      }
    }
    
    // โŒ Bad
    class Service {
      constructor() {
        this.repository = new Repository();
      }
    }
    
  3. Keep layers independent:

    // โœ… Good
    // Domain doesn't depend on Infrastructure
    
    // โŒ Bad
    // Domain imports from Infrastructure
    

Common Mistakes

  1. Mixing concerns:

    // โŒ Bad
    class User {
      save() { }
      render() { }
      validate() { }
    }
    
    // โœ… Good
    class User { }
    class UserRepository { save() { } }
    class UserView { render() { } }
    
  2. Tight coupling:

    // โŒ Bad
    class Service {
      constructor() {
        this.db = new Database();
      }
    }
    
    // โœ… Good
    class Service {
      constructor(db) {
        this.db = db;
      }
    }
    

Summary

Architectural patterns organize applications. Key takeaways:

  • MVC: Model, View, Controller separation
  • MVVM: ViewModel for data binding
  • MVP: Presenter handles logic
  • Flux: Unidirectional data flow
  • Clean Architecture: Layered design
  • Improves maintainability
  • Enables scalability
  • Facilitates testing

Next Steps

Comments