DEV Community

Cover image for Hexagonal Architecture in Plain JavaScript: Why I Use Objects as Interfaces (And Validate Them at Startup)

Hexagonal Architecture in Plain JavaScript: Why I Use Objects as Interfaces (And Validate Them at Startup)

How do you enforce contracts between layers in plain JavaScript? This is how I implemented hexagonal architecture — ports and adapters — in a Node.js monolith without TypeScript.

This is the system I found. It's been running in production since last summer without a single issue.

If you're coming from the previous post in this series, I promised I'd talk about those interfaces with throw new Error('Not implemented'). Here we are.

The problem with importing everything directly

Early on, my code looked like every other Node.js project. Controllers imported Firestore directly. The customer service imported the WooCommerce API. Everything worked, but everything was glued together.

The moment I realized this was a problem: I wanted to support shops without WooCommerce. Shops that only manage data from the app, no external CMS. I searched for where WooCommerce was used and found it hardcoded all over the place.

If I wanted a shop to work without WooCommerce, I'd have to add if/else checks everywhere. That wasn't going to scale.

I needed a way to say: "this service needs something that can create customers, update them, and delete them — but it doesn't care if it's WooCommerce, Shopify, or nothing at all."

Drawing boundaries with plain objects

The solution was what Hexagonal Architecture calls "ports." In my case, they're plain JavaScript objects — not classes — with methods that throw if you don't implement them:

// modules/customers/core/ports/CustomerRepository.js

const CustomerRepositoryInterface = {
  createCustomer: async (shopId, customerData) => {
    throw new Error('Not implemented');
  },
  updateCustomer: async (shopId, customerId, updates) => {
    throw new Error('Not implemented');
  },
  findById: async (shopId, customerId) => {
    throw new Error('Not implemented');
  },
  findByEmail: async (shopId, email) => {
    throw new Error('Not implemented');
  },
  // ... 19 methods total
};
Enter fullscreen mode Exit fullscreen mode

No inheritance. No abstract class. It's a contract: "this is what I expect you to know how to do."

The adapters, on the other hand, are classes — FirestoreCustomerRepository, WooCommerceCustomerAdapter. They implement the port's methods with real logic. Ports define the shape. Adapters do the work. That's the split.

I have three types of ports per entity:

  • Repository — the write side (Firestore)
  • ReadModel — the read side (Meilisearch or optimized queries)
  • CMSAdapter — the connection to the external CMS (WooCommerce, or nothing)

Each one defines its contract separately. The application service only knows the ports, never the concrete implementations.

But JavaScript won't tell you if a method is missing...

This is the real problem. You can build an adapter, implement 18 out of 19 methods, and JavaScript says nothing. Everything works great until someone calls the missing method in production.

My solution: validateImplementation(). Each port exports the list of required methods and a function that checks if an implementation has all of them:

// modules/customers/core/ports/CustomerRepository.js

const REQUIRED_METHODS = Object.keys(CustomerRepositoryInterface);

function validateImplementation(implementation) {
  const missingMethods = REQUIRED_METHODS.filter(
    method => typeof implementation[method] !== 'function'
  );

  if (missingMethods.length > 0) {
    throw new Error(
      `CustomerRepository missing methods: ${missingMethods.join(', ')}`
    );
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Notice: REQUIRED_METHODS is derived from the interface object with Object.keys(). If I add a method to the port, it's automatically required in validation. No duplicate list to maintain — that was an early mistake I already fixed.

Now here's the important part — each adapter validates itself when Node.js loads the module:

// Bottom of FirestoreCustomerRepository.js, BEFORE module.exports

validateImplementation(FirestoreCustomerRepository.prototype);
Enter fullscreen mode Exit fullscreen mode

It validates the .prototype of the class against the port's contract. Port is a plain object that defines the contract. Adapter is a class that implements it. The validation bridges the two.

If I forget a method, the app won't start. Not at 3am when a customer places an order. At startup. When I'm at my desk and can fix it in two minutes.

Dependency injection without a framework

With ports defined, application services receive their dependencies through the constructor. They don't know if there's Firestore, PostgreSQL, or a mock behind them:

// modules/customers/application/services/CustomerService.js

class CustomerService {
  constructor(dependencies) {
    this.customerRepository = dependencies.customerRepository;
    this.customerReadModel  = dependencies.customerReadModel;
    this.cmsAdapter         = dependencies.cmsAdapter;
    this.addressRepository  = dependencies.addressRepository;
    this.eventBus           = dependencies.eventBus;
    this.logger             = dependencies.logger || console;
  }
}
Enter fullscreen mode Exit fullscreen mode

No Inversify. No IoC container. Just an object with dependencies.

And where does it all come together? In a factory that acts as the composition root:

// modules/customers/application/services/index.js

function createCustomerServices(dependencies) {
  const { customerRepository, customerReadModel,
          cmsAdapter, addressRepository, eventBus } = dependencies;

  const customerService = new CustomerService({
    customerRepository, customerReadModel,
    cmsAdapter, addressRepository, eventBus,
  });

  const addressService = new CustomerAddressService({
    addressRepository, customerRepository, eventBus,
  });

  return { customerService, addressService };
}
Enter fullscreen mode Exit fullscreen mode

This is where you decide which concrete adapter goes to each port. The service never knows.

The Null Object that saved me

Back to the original problem: supporting shops without WooCommerce.

The bad option was adding if (shop.hasCMS) everywhere the adapter was called. The good option was creating a StandaloneCustomerAdapter — it implements the exact same port, but does nothing:

// modules/customers/infrastructure/adapters/StandaloneCustomerAdapter.js

class StandaloneCustomerAdapter {
  async syncCustomer(shopId, customer) {
    return { success: true }; // No CMS, nothing to sync
  }

  async *fetchAllCustomers(shopId) {
    // Empty generator — no external customers to fetch
  }

  getType() { return 'standalone'; }
  supports(feature) { return false; }
}

// Same validation pattern as every other adapter
validateImplementation(StandaloneCustomerAdapter.prototype);
Enter fullscreen mode Exit fullscreen mode

And a factory that picks the right one based on the shop's config:

// modules/customers/infrastructure/adapters/CustomerAdapterFactory.js

function createCustomerAdapter(cmsType, shopId, options = {}) {
  switch (cmsType?.toLowerCase()) {
    case 'woocommerce':
      return getWooCommerceCustomerAdapter(shopId, options);
    case 'standalone':
    case null:
    case undefined:
      return getStandaloneCustomerAdapter(options);
    default:
      throw new Error(`Unsupported CMS type: ${cmsType}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

CustomerService doesn't know which adapter it gets. And it doesn't care. That's the whole point.

Tomorrow, if I want to add PrestaShop, I create a new adapter, register it in the factory, and zero application services change.

The honest part

It's not perfect. Without TypeScript you lose autocomplete and types on hover. That hurts day to day.

validateImplementation() only checks that methods exist as functions — it doesn't validate signatures, return types, or anything beyond "is the method there?" It's a basic safety net, not a type system.

Sometimes I wonder if I should have started with TypeScript from the beginning.

But for a solo dev, the simplicity of "an object with methods" outweighs full type safety. I don't have a team of 10 where someone can break a contract without realizing it. It's just me, and validating at startup is enough to catch my own mistakes.

It's been in production since last summer. Not a single missing method at runtime.

What's next

Now we have ports separating the write side from the read side, and adapters implementing them. But there's a missing piece — when I write to Firestore, how does Meilisearch find out?

The answer is the event system. And it has a two-level naming trick — domain events (ecommerce.*) and infrastructure events (firestore.*) — that solved the coupling problem in a way I didn't expect.

That's the next post.


Do you enforce interfaces at runtime, or does TypeScript give you enough confidence?


Top comments (0)