DEV Community

Aleksandr Korolev
Aleksandr Korolev

Posted on AI-assisted

Complex systems emerge naturally. Simple systems are designed intentionally.

In software development, we have a bunch of cool techniques that help us keep complexity under control: encapsulation, abstraction, modules, interfaces, etc.

Have you ever thought about why we need them? They are not needed for computers; they are needed for us—humans. Our limit is 3–5 independent chunks of information at a time.

The famous number “7 ± 2,” proposed by George A. Miller in 1956, is still widely quoted. However, modern cognitive psychology considers it an overestimate for most reasoning tasks.

How do we fight quickly growing complexity in the age of LLMs?

Probably the first question we should answer is: should we fight it at all?

I have seen that some companies don't care what agents do; they care only about results. Here, I am considering only non-critical areas: no aviation, medical, or nuclear power plant software. You might ask: why not? LLMs make development fast; the cost per unit of LLM work is going down. But you might notice that even though the cost per unit is going down, overall costs are growing. From my point of view, not all companies can outsource code development entirely to LLMs.

I should also clarify that some complexity is unavoidable. What we should avoid is unnecessary complexity.

If you are convinced that we still need to own our code, let's see what we can do to understand and manage it.

Keeping complexity within human limits

Even before LLMs, creating code was not the main problem. Maintenance was. Without proper design, a codebase quickly becomes unmaintainable. I think you've felt this frustration when a tiny task becomes a one-week project. I hope you've encountered the opposite, too: a codebase where a task takes as long as you think it should.

LLMs can tolerate some aspects of code that we struggle with: variations in syntax and visual presentation, and keeping track of long execution flows in methods, functions, and classes. But structural quality remains important.

1. SRP: the single responsibility principle

I'm not going to explain what this principle is, only why it still matters.

The problem:

LLMs can generate code faster than humans can understand it. Without discipline, your codebase becomes a maze of moving parts, even though each piece is reasonable.

The solution:

SRP isn't optional anymore. It's the gating mechanism that prevents uncontrolled growth. Every generated class must have one clear responsibility. If the LLM generates five classes that are tangled together, reject the result and ask for refactoring.

What does this mean in practice? If a feature needs to touch five different services, maybe you're building it incorrectly. This is not only about complexity but also about LLM costs: the model needs to touch more files, and the context window might fill up quickly.

Simple steps to get started

1. Review code for architecture, not just correctness

❌ Old: "Does it work?"
✓ New: "Does it work? AND does it respect SRP?"
Enter fullscreen mode Exit fullscreen mode

Questions to ask about every LLM-generated PR:

  • “How many reasons could this class have to change?”
  • “What does this depend on?”
  • “Could I explain this to a new person in two minutes?”
  • “Does this have side effects I can't see?”

2. Establish a “no monolithic services” rule

# This PR gets rejected, even if it works
class DataProcessor < ApplicationService
  def initialize(data)
    @data = data
  end

  def call
    fetch_data
    validate_data
    transform_data
    score_data
    export_data
    update_cache
    send_webhooks
  end
end

# Each of these should be its own service
Enter fullscreen mode Exit fullscreen mode

3. Require explicit dependencies

# ❌ Hidden dependencies — rejected
class SurveyScorer
  def call
    complexity = SurveyComplexityScorer.call(survey_uuid)
    # Where does SurveyComplexityScorer come from? Unknown.
  end
end

# ✓ Explicit dependencies — accepted
class SurveyScorer
  def initialize(complexity_scorer:)
    @complexity_scorer = complexity_scorer
  end

  def call
    complexity = @complexity_scorer.call(survey_uuid)
    # Clear: I depend on this injected service
  end
end
Enter fullscreen mode Exit fullscreen mode

Level of abstraction

What “level of abstraction” means

  • Code operates at one conceptual “height”: high-level logic, mid-level operations, or low-level details, without mixing them all together.
  • High-level code reads like a specification: “Find brands available in waves X and countries Y,” rather than “Iterate over arrays and compute intersections.”
  • Each method has one abstraction level: it does not decide a strategy and perform array manipulation in the same method.
  • Low-level operations are hidden behind clear names: compute_brand_intersection() instead of exposing array1 & array2.
  • The logic is easy to follow: the reader doesn't need to switch between “What are we trying to do?” and “How does this loop work?”
  • Each layer has clear vocabulary: domain terms such as waves, countries, and brands at the top; operations such as map, select, and inject at the bottom, without mixing them.
  • Intent is obvious: the code says why you're doing something, rather than burying it in how you're doing it.
  • Bugs are easier to spot: “That calculation is wrong,” rather than “Wait, what is this loop trying to do?”

What “level of abstraction” does not mean

  • Not “one level per file”: a file can contain different levels, as long as they are clearly separated.
  • Not “never use low-level operations”: arrays, loops, and string manipulation are fine; they just shouldn't be mixed with policy decisions.
  • Not “always extract every loop into a helper”: sometimes inline code is clearer. It's about consistency, not mechanical extraction.
  • Not “avoid implementation details”: you need implementation details; just keep them out of high-level logic.
  • Not “forbid technical terms”: use technical terms at the technical layer and domain terms at the domain layer.
  • Not “every variable must have a description”: if a method stays at one abstraction level, the code is self-documenting.
  • Not “a rigid hierarchy”: abstraction layers communicate and depend on each other. It's not a pyramid; it's a conversation.
  • Not “separate from SRP”: they are different—one concerns reasons to change, while the other concerns conceptual clarity—but they work together.

How to achieve it

1. Read the code from the top down

Ask: if someone reads this function from top to bottom, can they understand the intent without reading the implementation details?

# ❌ Bad: High-level intent is buried in low-level details
def process_survey
  survey = fetch_survey_from_api
  parsed = JSON.parse(survey)
  groups = parsed['groups'].map { |g| g.with_indifferent_access }
  # ... 20 more lines of array operations
end

# ✓ Good: High-level intent is clear; details are hidden
def process_survey
  survey = fetch_survey
  groups = extract_groups_from(survey)
  score_groups(groups)
end

private

def extract_groups_from(survey)
  parsed = JSON.parse(survey)
  parsed['groups'].map { |g| g.with_indifferent_access }
end
Enter fullscreen mode Exit fullscreen mode

2. Name things at their abstraction level

# ❌ Bad: A domain term mixed with implementation details
def find_common_wave_countries
  c_ids.flat_map { |c| hash[c] }.uniq & other_hash.keys
end

# ✓ Good: Domain terms at the high level; operations at the low level
def find_common_wave_countries
  our_countries & available_countries
end

private

def our_countries
  c_ids.flat_map { |c| hash[c] }.uniq
end

def available_countries
  other_hash.keys
end
Enter fullscreen mode Exit fullscreen mode

3. Use helper methods to hide complexity

# ❌ Bad: Business logic is mixed with loop details
def eligible_waves
  all_waves.select do |wave|
    next unless @wave_country_brands[wave].present?
    @wave_country_brands[wave].values_at(*country_ids).all?
  end
end

# ✓ Good: Business logic is clear; details are in a helper
def eligible_waves
  all_waves.select { |wave| has_all_countries?(wave) }
end

private

def has_all_countries?(wave)
  return false unless @wave_country_brands[wave].present?
  @wave_country_brands[wave].values_at(*country_ids).all?
end
Enter fullscreen mode Exit fullscreen mode

4. Keep one abstraction level per method

# ❌ Bad: Mixing levels
def calculate_score
  # High-level: Decide what to score
  components = [:burden, :options, :branching]

  # Low-level: Extract from a JSON structure
  burden = body.dig(:metrics, :burden).to_f
  options = body.dig(:metrics, :options).to_f
  branching = body.dig(:metrics, :branching).to_f

  # High-level: Compute a weighted score
  (burden * 0.35) + (options * 0.30) + (branching * 0.35)
end

# ✓ Good: Separate levels
def calculate_score
  burden = extract_burden_metric
  options = extract_options_metric
  branching = extract_branching_metric

  weighted_score([burden, options, branching])
end

private

def extract_burden_metric
  body.dig(:metrics, :burden).to_f
end

def weighted_score(components)
  components[0] * 0.35 + components[1] * 0.30 + components[2] * 0.35
end
Enter fullscreen mode Exit fullscreen mode

5. Use the “Could I explain this to a non-programmer?” test

# ❌ Bad: A non-programmer can't follow this
def filtered_restrictions
  filtered_wave_ids.select { |wid| wave_country_brands[wid]&.values_at(*country_ids)&.all? }.compact
end

# ✓ Good: The intent is clear even to a non-programmer
def filtered_restrictions
  waves_that_have_all_requested_countries
end
Enter fullscreen mode Exit fullscreen mode

How do you keep complexity under control?

Top comments (0)