When your code speaks Rails instead of the domain

I had a different plan for this section: four articles explaining the foundations of DDD compared to a typical Rails way approach. Then I asked myself: can AI also write this? And since the answer was yes for about 75% of the content, I decided to delete all four articles and start over.

All that matters now, when you write about software engineering, is the very own stuff that AI can’t produce: honest opinions, battle scars, bitter production lessons, bold statements, and the taste for picking or recommending what’s right based on the complex context.

A world without the DDD has a recurring problem: the telephone game, software-engineering edition. I worked on an HR project; every two or three months, I had a call with the clients, people actually using the platform. One conversation about our Workday integration went like this:

A clear gap in the communication. The kind of gap that leads to a financial loss, a missed deadline, or a scope that quietly misses the point. Nobody in the chain ever examined the word “hired”. Each person assumed they knew what it meant and passed it along.

A customer success specialist just received a request from a client to build a feature that displays hired candidates from the Workday system. A new Jira ticket has landed: build a report of hired candidates from Workday. The developer implemented it after reviewing the API documentation.

This is precisely what a shared language, agreed before any code, would have caught on day one. The number-one goal of ubiquitous language in DDD is to remove ambiguity.

Generalization that costs

Let’s stay a little longer in the HR context and the Workday integration, as it clearly shows how the lack of proper DDD hurts Rails projects. We used to operate on the Job model and thought that the words Requisition and Position were just synonyms for it. It was a mistake.

Within the Workday integration, the position was a seat in the organizational structure that existed whether or not it was filled. A requisition was the authorization to hire, and it could be evergreen or multi-position. But in the system, the fat Job model represented both, with tens of columns to handle all possible business cases. Just to mention a few:

The other models followed the same approach. A popular approach to making models slim is to extract logic into service objects. We also had plenty of them. There was a separation of integrations, but it was just a false impression, as the typical service looked like that:

module Integration
  module Services
    class JobCreationService
      def call
        return existing_job if existing_job

        job = Job.new(job_attributes)
        job.save!

        job.perform_some_other_action

        job
      end

      private

      def existing_job
        # query DB
      end

      def job_attributes
        # prepare attributes by calling API and various parsing methods
        {
          some_key: some_value
        }
      end

      def some_value
        # parse API response
      end

      def some_raw_value
        # call API
      end
    end
  end
end

You can see the violation of many rules above. First of all, the service does not just create a job. It calls the database, calls the API, parses the response, and parses other attributes, and you don’t know the radius of changes when you hit Job#save because of multiple callbacks on the model.

A typical service object is created solely to satisfy the requirements of a Jira ticket for a given task. No anti-corruption layer (a translation boundary that stops Workday's model from leaking into ours), crossing multiple boundaries, and tight coupling between the external source of data and the internal state. This is the kind of mess that produces bugs like the bonus we paid.

Naming the disease

None of this would have happened if we had examined the language first. We didn't, so we built a model that generalizes concepts the business keeps distinct. We treated Requisition and Position as synonyms for Job. We treated a Workday hired status as a hired person. In both cases, the code ended up speaking in terms of Rails rather than the domain. That's the disease. DDD is the cure.

It starts with a shared language among everyone involved: the client, the customer success specialist, and the developer all meaning the same thing by the same word. The two stories show the two sides of DDD:

But a shared language is not enough on its own. Before you can protect it in code, you have to find where one language ends, and another begins - and that means discovering subdomains and drawing the bounded contexts around them. That's where the next article begins.