Practice · SOLID · SRP · Card 1
Spot the responsibilities in this controller action
The code below works, ships, and is normal-looking Rails. It's also doing four different jobs that should probably live elsewhere. Name them.
The code
A signup controller. What does this action actually do, end to end?
def create
@user = User.new(user_params)
@user.password = SecureRandom.hex(8) if user_params[:password].blank?
if @user.save
Stripe::Customer.create(email: @user.email, name: @user.name)
WelcomeMailer.with(user: @user).welcome_email.deliver_later
Rails.logger.info "signup: #{@user.id} from IP #{request.remote_ip}"
redirect_to dashboard_path, notice: "Welcome!"
else
render :new, status: :unprocessable_entity
end
end The question
List the distinct responsibilities mixed into this action. Then think about where each one should live.
Take a moment. A controller's actual job is to read the request, call something to do the work, and render the response. Anything in this action that isn't one of those three is misplaced.
The four responsibilities
- Default values. Generating a random password if none was given is a model concern, not a controller one. It belongs in a
before_validationon User, or in a User factory. - External integration. Creating a Stripe customer is a side-effect tied to "a User was created." It belongs in a service object or, if you only need it triggered consistently, an
after_commitcallback that enqueues a job. The controller shouldn\'t talk to Stripe directly. - Communication. Sending the welcome email is another "a User was created" side effect. Belongs in the same place as the Stripe call (a job, or an
after_commit). - Logging. A signup audit log is a domain event. It should be emitted by the domain code (the model or service), not by the controller. The IP comes from the request, so you\'d pass it as context.
The shape of the fix
The senior version of this action is short. It validates the input, calls a service or domain method, and renders the response. The controller is the front door, not the kitchen.
# A skinny version:
def create
result = SignUpUser.call(user_params, ip: request.remote_ip)
if result.success?
redirect_to dashboard_path, notice: "Welcome!"
else
@user = result.user
render :new, status: :unprocessable_entity
end
end Now: the password default lives in the model. The Stripe customer creation lives in SignUpUser (or a job it enqueues). The welcome email lives in the same place. The signup log lives there too. Each piece moved to a place where it makes sense, and each can be tested in isolation.
You don\'t have to do this on day one. You do have to recognize the shape, because the only way fat controllers stay fat is by no one noticing them growing.
Theory
For the full walkthrough of service-object extraction and other SRP moves, read SOLID · Service Objects and the other six SRP lessons.