Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 11 min read

Stop Letting AI Decide Your JavaScript Architecture

AI can generate working code in seconds. But working code and good architecture are two very different things. We've reached a point where generating JavaScript is no longer the difficult part. You can ask an AI coding

AI can generate working code in seconds. But working code and good architecture are two very different things.

We've reached a point where generating JavaScript is no longer the difficult part.

You can ask an AI coding assistant to build a slider, a modal system, a router, or an entire application, and chances are you'll get something functional within minutes.

That's impressive.

But there's a question we don't ask often enough:

Who is making the architectural decisions?

When AI creates a class, introduces an abstraction, extends a base component, or splits something into five different modules, it's making decisions that will influence how your codebase evolves.

And those decisions aren't necessarily wrong.

They're just not necessarily right, either.

The problem is that we tend to evaluate AI-generated code by asking one question:

Does it work?

I think we should be asking another:

Will this still make sense when the requirements change?

Let's explore that with a small JavaScript experiment.

1. The Experiment: One Slider, Three Architectures

Imagine we're building a lightweight JavaScript slider.

Nothing particularly complicated.

Our initial requirements are simple:

  • Navigate between slides.
  • Support autoplay.
  • Emit events when slides change.
  • Expose a small public API.
  • Allow functionality to be extended later.

We could give an AI coding assistant this prompt:

Build a lightweight JavaScript slider with navigation, autoplay, and change events. Use modern JavaScript.

That's it.

No architectural requirements. No preferences for composition, inheritance, or functional programming.

Now here's the interesting part.

There isn't one objectively correct implementation.

An AI might generate a single class, a hierarchy of classes, several modules, or a collection of functions.

All of them could work.

So rather than pretending one particular architecture is what every AI would generate, let's compare three valid approaches and see what happens when we add complexity.

2. Approach One: The Everything Class

Our first implementation puts all the functionality inside a single class.

class Slider {
  #index = 0;
  #timer = null;
  #listeners = new Set();

  constructor(slides, interval = 3000) {
    this.slides = slides;
    this.interval = interval;
  }

  next() {
    this.#index =
      (this.#index + 1) % this.slides.length;

    this.#emitChange();
  }

  onChange(callback) {
    this.#listeners.add(callback);

    return () => this.#listeners.delete(callback);
  }

  startAutoplay() {
    this.stopAutoplay();

    this.#timer = setInterval(() => {
      this.next();
    }, this.interval);
  }

  stopAutoplay() {
    clearInterval(this.#timer);
    this.#timer = null;
  }

  #emitChange() {
    for (const callback of this.#listeners) {
      callback(this.#index);
    }
  }
}

For the sake of the example, assume the slider receives a non-empty array of slides.

This implementation isn't terrible.

Actually, for a small project, it's pretty reasonable.

It has encapsulated state, a straightforward public API, and very little indirection.

But notice what happens as the library grows.

We want to introduce:

  • Previous-slide navigation.
  • Looping and non-looping modes.
  • Different autoplay behaviors.
  • Keyboard controls.
  • Custom transitions.
  • Touch and swipe interactions.

Now the Slider class starts accumulating responsibilities.

Navigation logic, event management, timing, input handling, and rendering all live in the same place.

Every new feature potentially touches the same class.

The problem isn't that we used a class. It's that one class is becoming responsible for too many things.

Still, we shouldn't immediately rewrite it.

Complexity needs to justify abstraction.

3. Approach Two: Inheritance

One way to organize this functionality is through inheritance.

class BaseSlider {
  constructor(slides) {
    this.slides = slides;
    this.index = 0;
  }

  next() {
    this.index =
      (this.index + 1) % this.slides.length;
  }
}

class AutoplaySlider extends BaseSlider {
  startAutoplay() {
    this.timer = setInterval(() => {
      this.next();
    }, 3000);
  }

  stopAutoplay() {
    clearInterval(this.timer);
  }
}

class AnimatedSlider extends AutoplaySlider {
  animate() {
    // Run transition animation
  }
}

This looks nicely organized.

We have a base class and specialized implementations.

But something subtle has happened.

An animated slider now inherits autoplay behavior, whether it needs it or not.

What if we want an animated slider without autoplay?

Or autoplay without animations?

What if we want multiple interchangeable animation engines?

We could introduce more subclasses, but eventually we risk creating an increasingly complicated hierarchy.

BaseSlider
  └── AutoplaySlider
       └── AnimatedSlider
            └── TouchSlider

The hierarchy is starting to describe combinations of features rather than genuine relationships between types.

And that's where inheritance becomes uncomfortable.

Inheritance is useful when one class is a meaningful specialization of another.

It becomes problematic when we're using it simply to assemble features.

Not every reusable behavior needs to become a parent class.

4. Approach Three: Composition

Let's try a different approach.

Instead of asking what our slider should inherit, let's ask what responsibilities it needs.

We identify three pieces:

  • A navigation system.
  • An autoplay controller.
  • An event system.

Each can be implemented independently.

class Navigation {
  #index = 0;

  constructor(length) {
    this.length = length;
  }

  next() {
    this.#index =
      (this.#index + 1) % this.length;

    return this.#index;
  }
}

class Autoplay {
  #timer = null;

  start(callback, interval) {
    this.stop();

    this.#timer = setInterval(
      callback,
      interval
    );
  }

  stop() {
    clearInterval(this.#timer);
    this.#timer = null;
  }
}

Our main slider becomes responsible for coordinating these pieces.

class Slider {
  #navigation;
  #autoplay;
  #listeners = new Set();

  constructor(slides, options = {}) {
    if (!slides.length) {
      throw new Error(
        "Slider requires at least one slide"
      );
    }

    this.#navigation =
      options.navigation ??
      new Navigation(slides.length);

    this.#autoplay =
      options.autoplay ??
      new Autoplay();
  }

  next() {
    const index = this.#navigation.next();

    for (const callback of this.#listeners) {
      callback(index);
    }
  }

  onChange(callback) {
    this.#listeners.add(callback);

    return () => this.#listeners.delete(callback);
  }

  startAutoplay(interval = 3000) {
    this.#autoplay.start(
      () => this.next(),
      interval
    );
  }

  destroy() {
    this.#autoplay.stop();
    this.#listeners.clear();
  }
}

Now navigation and autoplay are separate responsibilities.

We can test them independently.

We can substitute implementations without changing the slider's public API.

And we haven't needed a complicated class hierarchy.

That's composition.

But there's something important worth mentioning.

Composition isn't automatically better just because we've created more classes.

If our slider only needs ten lines of logic and will never grow, introducing multiple modules might make the code harder to understand.

The goal isn't to maximize modularity.

It's to introduce useful boundaries where they actually make sense.

5. Eight OOP Techniques That Solve Different Problems

This is where I think AI-generated code becomes particularly interesting.

We often discuss object-oriented programming as though it were one architectural approach.

But OOP includes several techniques, each solving a different problem.

Understanding their differences makes it much easier to decide when to use them.

1. Encapsulation: Protect Internal State

Encapsulation controls access to an object's internal data and behavior.

class Slider {
  #index = 0;

  next() {
    this.#index++;
  }

  get index() {
    return this.#index;
  }
}

Consumers can read the current index, but they can't directly modify the private field.

Use it when: you want to enforce state invariants and prevent consumers from depending on implementation details.

2. Inheritance: Specialize Existing Behavior

Inheritance allows a class to reuse and extend another class's behavior.

class Component {
  destroy() {
    console.log("Destroyed");
  }
}

class Slider extends Component {}

Here, Slider inherits destroy().

Use it when: there's a genuine is-a relationship and the subclass can safely fulfill the expectations of its parent.

Avoid using inheritance purely to share unrelated functionality.

3. Composition: Combine Independent Behaviors

Composition builds complex objects from smaller objects.

class Slider {
  constructor(navigation, autoplay) {
    this.navigation = navigation;
    this.autoplay = autoplay;
  }
}

The slider has a navigation system and has an autoplay controller.

It doesn't need to inherit from either.

Use it when: responsibilities can evolve independently or features need to be combined in different ways.

4. Polymorphism: Interchange Implementations

Polymorphism allows different objects to be used through the same expected interface.

Consider a page-transition library.

class FadeTransition {
  run(element) {
    return element.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 300 }
    ).finished;
  }
}

class SlideTransition {
  run(element) {
    return element.animate(
      [
        { transform: "translateX(100%)" },
        { transform: "translateX(0)" }
      ],
      { duration: 300 }
    ).finished;
  }
}

Both expose the same run() method.

Our transition controller doesn't need to care which implementation it's using.

class TransitionController {
  constructor(effect) {
    this.effect = effect;
  }

  run(element) {
    return this.effect.run(element);
  }
}

Use it when: different implementations share a meaningful contract.

JavaScript doesn't require formal interfaces to achieve this. Objects can satisfy contracts through their behavior.

5. Abstraction: Hide Unnecessary Complexity

Abstraction exposes what a system does without forcing consumers to understand how it works.

slider.next();
slider.startAutoplay();
slider.destroy();

The consumer doesn't need to know how timers, event listeners, or internal state are managed.

That's the purpose of a good public API.

Use it when: implementation details would otherwise leak into consuming code.

Encapsulation and abstraction are related, but they're not identical: encapsulation controls access, while abstraction simplifies the model exposed to consumers.

6. Dependency Injection: Receive Dependencies

Dependency injection means a class receives dependencies rather than constructing all of them internally.

class PageLoader {
  constructor(fetcher = fetch) {
    this.fetcher = fetcher;
  }

  async load(url) {
    const response = await this.fetcher(url);

    if (!response.ok) {
      throw new Error("Request failed");
    }

    return response.text();
  }
}

Now we can provide another fetch implementation, including one for tests.

Use it when: dependencies need to be replaced, configured, or tested independently.

You don't need a dependency-injection framework to use dependency injection.

7. Delegation: Let the Right Object Do the Work

Delegation is about forwarding responsibility to another object.

class Slider {
  constructor(navigation) {
    this.navigation = navigation;
  }

  next() {
    return this.navigation.next();
  }
}

The public API belongs to Slider, but navigation logic lives in Navigation.

Use it when: a class should coordinate behavior without implementing every operation itself.

Delegation and composition often work together, but they describe different things.

8. Mixins: Share Behavior Across Classes

Mixins allow behavior to be added to multiple classes without creating a parent-child relationship.

const EventEmitter = {
  emit(event) {
    console.log(event);
  }
};

class Slider {}
class Modal {}

Object.assign(Slider.prototype, EventEmitter);
Object.assign(Modal.prototype, EventEmitter);

Both classes gain an emit() method.

Use it when: sharing behavior this way is appropriate and the resulting method ownership remains clear.

Personally, I'd be careful with mixins. They can introduce naming collisions and make it difficult to understand where behavior originates.

In many cases, an explicit composed object is easier to maintain.

6. The Real Test: Change the Requirements

Let's return to our slider.

Imagine a new requirement arrives:

We need to support different autoplay strategies. Some sliders should advance at a fixed interval, while others should wait until the current animation finishes.

This is where architectural decisions become visible.

With the everything class, we might add conditionals to the existing timer logic.

With the inheritance-based architecture, we might introduce more subclasses or override existing methods.

With composition, we can introduce another autoplay implementation.

class AnimationAwareAutoplay {
  #running = false;

  async start(advance, delay) {
    this.#running = true;

    while (this.#running) {
      await new Promise(resolve =>
        setTimeout(resolve, delay)
      );

      if (!this.#running) break;

      await advance();
    }
  }

  stop() {
    this.#running = false;
  }
}

For simplicity, this example shows the scheduling mechanism; a production implementation would also need cancellation and lifecycle handling for pending waits.

The important part is that we can replace the autoplay behavior without rewriting the slider itself.

That's a meaningful benefit of composition.

But here's the catch.

The best architecture isn't necessarily the one with the most flexibility. It's the one with the appropriate amount of flexibility.

If we never need multiple autoplay strategies, a separate abstraction might not be worth maintaining.

This is why architectural decisions should be evaluated against real requirements, not just theoretical extensibility.

And it's exactly the kind of reasoning I want an AI coding assistant to perform.

7. The Instructions I Give AI Before It Writes JavaScript

Instead of asking AI to "write clean, scalable, maintainable code," I'd rather give it specific architectural guidance.

Here's a reusable set of instructions for a CLAUDE.md, AGENTS.md, or similar project configuration.

## JavaScript Architecture Guidelines

### Core principles
- Prefer composition over inheritance.
- Favor simple solutions over speculative abstractions.
- Keep responsibilities focused and explicit.
- Protect internal state through encapsulation.
- Design small, predictable public APIs.
- Follow KISS, DRY, and YAGNI.

### Classes and functions
- Use classes when managing persistent state,
  identity, or an object lifecycle.
- Prefer functions for stateless transformations.
- Avoid classes that only group unrelated helpers.
- Avoid unnecessary inheritance hierarchies.

### Dependencies
- Use dependency injection when dependencies
  need to be configurable or replaceable.
- Prefer explicit dependencies.
- Avoid introducing containers or frameworks
  without a concrete requirement.

### Extensibility
- Use polymorphism when multiple implementations
  share a meaningful contract.
- Prefer delegation over duplicating responsibilities.
- Avoid premature design patterns.
- Do not create interfaces or abstract layers
  for hypothetical future use cases.

### Before introducing an abstraction
Ask:
1. What problem does this abstraction solve?
2. Do multiple implementations actually exist?
3. Does it reduce coupling or just move code?
4. Would a simple function be clearer?
5. Is the added complexity justified?

### General rule
Choose the simplest architecture that satisfies
current requirements while allowing reasonable
future changes.

Do not overengineer.

These guidelines aren't meant to replace architectural judgment.

They're meant to establish a useful default.

And defaults matter.

Especially when a coding assistant can generate hundreds of lines before you've had time to question the first architectural decision.

8. What I'd Actually Evaluate in AI-Generated Code

I wouldn't judge an implementation by counting classes.

Or measuring how many design patterns it uses.

Or checking whether it follows every SOLID principle.

Instead, I'd ask:

Can I understand it?

Does the code communicate its responsibilities clearly, or do I need to jump between six abstractions to understand a simple operation?

Can I change it?

What happens when a new feature requires modifying an existing behavior?

Can I test it?

Can I test navigation without starting timers or interacting with the DOM?

Are the boundaries meaningful?

Are classes separated because they genuinely represent different responsibilities, or simply because someone decided smaller files are always better?

Is the complexity justified?

Would a function solve the same problem more effectively?

These questions apply to code written by humans and AI alike.

The difference is that AI makes it easier to generate complex structures without experiencing the effort of writing them.

And that can make unnecessary complexity harder to notice.

9. Composition Isn't a Religion

There's a danger in turning any architectural recommendation into an absolute rule.

"Always use composition" isn't much better than "always use classes."

Inheritance has legitimate uses.

A single class can be the right solution.

Functional programming can be more appropriate than OOP.

And sometimes a straightforward procedural implementation is all we need.

JavaScript is a multi-paradigm language.

That's one of its strengths.

We don't have to commit to one technique for every problem.

A useful default might be:

Functions for stateless logic. Classes for stateful objects. Composition for assembling independent behaviors.

But even that is a guideline, not a law.

The objective is to make deliberate decisions instead of following patterns automatically.

10. Stop Outsourcing Architectural Judgment

AI coding assistants are becoming remarkably good at producing working implementations.

And I think that's great.

But generating code and designing software aren't quite the same thing.

Architecture involves understanding constraints, anticipating likely changes, identifying meaningful boundaries, and deciding which complexity is worth introducing.

Those decisions require context.

Context that isn't always present in a prompt.

So the next time an AI assistant generates a perfectly functional class hierarchy, don't immediately accept it.

Ask why inheritance was necessary.

Ask whether composition would simplify it.

Ask whether the abstraction solves a real problem.

And, perhaps most importantly, ask whether the entire thing could have been a function.

AI doesn't need more design patterns. It needs better reasons to use them.

And until it has enough context to make those decisions reliably, architectural judgment should remain part of our job.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.