Blog

GoLazy Blog

Why GoLazy Uses SVC Instead of MVC

Why GoLazy puts business behavior in services instead of splitting it between database models and service objects.

Marker diagram connecting services, views, and controllers while crossing out an ambiguous tangle.

Published

There is a moment in the life of many Rails applications when somebody creates app/services.

The directory is not part of the original story. The story was model, view, controller. Controllers handle the request. Views render the result. Models contain the business logic. Fat models, skinny controllers. Nice, memorable, and genuinely productive.

Then the application grows.

Creating an invoice now means loading an account, checking a subscription, applying regional tax rules, reserving a sequence number, writing several records, scheduling a job, and sending an email. None of those steps is especially strange. The strange part is deciding which one of the models owns the operation.

So somebody creates CreateInvoiceService.

Nothing explodes. The application may even improve. But it now has two answers to a basic question: where does the business logic live?

Some of it lives in models. Some of it lives in services. The distinction is usually explained with words like "local behavior" and "orchestration," which sound precise until three people have to decide where the next rule belongs.

The services directory is trying to tell us something.

Active Record is valuable

Rails' Active Record model is one of the most productive abstractions in web development. It gives the developer a place where persisted data and business language can meet. Open a well-designed model and you can understand a surprising amount about the application.

That is priceless.

It is also easy to caricature. Active Record is not bad because it knows about the database. A model with meaningful behavior is often much better than a collection of anemic structs passed through procedural code. The ability to ask an object a business question can make code readable in ways that a repository full of generic CRUD methods never will.

The problem appears when the model is expected to be the center of all business behavior, including behavior that does not have one natural model.

A business operation may cross five persisted types. It may call an external system. It may not write to a database at all. It may coordinate a transaction and then schedule work that must happen outside it. Choosing one Active Record model as the owner can become arbitrary. Putting the operation in a service is reasonable, but now business behavior has two homes.

That split is not catastrophically bad. Plenty of good Rails applications use service objects. The side effect is architectural ambiguity: developers must continually decide whether a rule belongs inside a model or inside the service coordinating several models.

GoLazy avoids that decision by removing the model from the framework architecture.

Service, view, controller

GoLazy applications have three main boundaries:

  • Services own business rules, use cases, domain types, data access, and domain-facing integrations.
  • Controllers translate HTTP into service calls, then choose a response, status, redirect, or view.
  • Views present the result.

That is service-view-controller: SVC.

The service is not necessarily a network service. It does not need to be a microservice, a daemon, or a process with a logo and its own Kubernetes namespace. It is an application boundary expressed as a Go package.

A service can be small. It can contain a few functions and types. It can use PostgreSQL, SQLite, an API, embedded files, memory, or all of them. It owns a piece of the business and exposes operations in the language of that business.

The controller should not know how the data is stored. It should know that it can create an invoice, publish a post, register a vote, or load an account summary. HTTP is the controller's problem. The business operation is the service's problem.

The view should not discover business rules by calling the database from a template. It receives the result and renders it. This is not revolutionary. That is rather the point.

The database is an implementation detail

GoLazy ships without a database and without an ORM.

That does not mean database work is unimportant. It means the framework does not know enough about your business to choose its persistence model.

Your application may use SQLC and PostgreSQL. It may use Ent, Bun, GORM, or handwritten SQL. It may talk to several databases. It may mostly call another service. It may not need persistent data at all.

Whatever you choose belongs behind the service boundary. GoLazy can help initialize dependencies, run migrations, manage jobs, and expose development information, but it does not pretend that a database row is automatically the domain model of every application.

This is your fucking problem.

I mean that positively.

It is your business. You know the invariants, the operations, the ugly exceptions, and the vocabulary better than a web framework ever will. A framework should provide a coherent place for that knowledge. It should not manufacture a generic business architecture and congratulate itself for saving you from understanding your own product.

What the framework does own

This does not make GoLazy unopinionated. Quite the opposite.

GoLazy has strong opinions about the web application around the services: routing, controllers, views, server rendering, assets, Turbo, Tailwind, SEO, caching, jobs, development tooling, deployment, and upgrades.

Those are repeated application problems. Solving them coherently is the framework's job.

The rules for whether an invoice can be cancelled are not repeated framework problems. They are business rules. Solving them is the application's job.

That boundary matters. "Bring your own everything" is not a framework philosophy; it is a shopping list. But claiming ownership over the business model is not necessary to provide an opinionated application workflow.

GoLazy draws the line at services.

What we lose

SVC has costs.

You do not get one framework object that automatically provides persistence, validation, associations, callbacks, dirty tracking, and business behavior. You must design service APIs. You must decide what your domain types mean. You must choose how data crosses the persistence boundary.

For a small CRUD application, an Active Record model can be wonderfully efficient. SVC may look like extra structure when the entire business really is close to a few database tables.

That is fine. GoLazy is not proof that Rails is wrong. Rails remains exceptionally good at the model it chose.

GoLazy chooses a different center because Go is a different language and because I want business behavior to have one obvious home as applications grow.

Not the controller. Not the view. Not half in a persisted model and half in a coordinating object created three years later.

The service owns the business. Everything else is an adapter.