Ruby and AOP: Decouple your code even more

… and check why 5600+ Rails engineers read also this

Ruby and AOP: Decouple your code even more

We, programmers, care for our applications’ design greatly. We can spend hours arguing about solutions that we dislike and refactoring our code to loose coupling and weaken dependencies between our objects.

Unfortunately, there are Dark Parts in our apps - persistence, networking, logging, notifications… these parts are scattered in our code - we have to specify explicit dependencies between them and domain objects.

Is there anything that can be done about it or is the real world a nightmare for purists? Fortunately, a solution exists. Ladies and gentlemen, we present aspect-oriented programming!

A bit of theory

Before we dive into the fascinating world of AOP, we need to grasp some concepts which are crucial to this paradigm.

When we look at our app we can split it into two parts: aspects and components. Basically, components are parts we can easily encapsulate into some kind of code abstraction - a methods, objects or procedures. The application’s logic is a great example of a component. Aspects, on the other hand, can’t be simply isolated in code - they’re things like our Dark Parts or even more abstract concepts - such as ‘coupling’ or ‘efficiency’. Aspects cross-cut our application - when we use some kind of persistence (e.g. a database) or network communication (such as ZMQ sockets) our components need to know about it.

Aspect-oriented programming aims to get rid of cross-cuts by separating aspect code from component code using injections of our aspects in certain join points in our component code. The idea comes from Java community and it may sound a bit scary at first but before you start hating - read an example and everything should get clearer.

Let’s start it simple

Imagine: You build an application which stores code snippets. You can start one of the usecases this way:

class SnippetsUseCase
  attr_reader :repository, :logger, :snippets

  def initialize(snippets_repository = SnippetsRepository.new, logger = Logger.new)
    @repository = snippets_repository
    @logger = logger

    @snippets = []
  end

  def user_pushes(snippet)
    snippets << snippet

    repository.push(snippet, 
                    success: self.method(:user_pushed),
                    failure: self.method(:user_fails_to_push))
  end

  def user_pushed(snippet)
    logger.info "Successfully pushed: #{snippet.name} (#{snippet.language})"
  end

  def user_fails_to_push(snippet, pushing)
    snippets.delete(snippet)

    logger.error "Failed to push the snippet: #{pushing.error}"
  end
end

Here we have a simple usecase of inserting snippets to the application. To perform some kind of SRP check, we can ask ourselves: What’s the responsibility of this object? The answer can be: It’s responsible for pushing snippets scenario. So it’s a good, SRP-conformant object.

However, the context of this class is broad and we have dependencies - very weak, but still dependencies:

  • Repository object which provides persistence to our snippets.
  • Logger which helps us track activity.

Use case is a kind of a class which belongs to our logic. But it knows about aspects in our app - and we have to get rid of it to ease our pain!

Introducing advice

I have told you about join points. It’s a simple, yet abstract idea - and how can we turn it into something specific? What are the join points in Ruby? A good example of join point (used in the aquarium gem) is an invocation of method. We specify how we inject our aspect code using advice.

What are advice? When we encounter a certain join point, we can connect it with an advice, which can be one of the following:

  • Evaluate code after given join-point.
  • Evaluate code before given join-point.
  • Evaluate code around given join-point.

While after and before advice are rather straightforward, around advice is cryptic - what does it mean to “evaluate code around” something?

In our case it means: Don’t run this method. Take it and push to my advice as an argument and evaluate this advice. In most cases after and before advice are sufficient.

Fix our code

We’ll refactor our code to embrace aspect-oriented programming techniques. You’ll see how easy it is.

Our first step is to remove dependencies from our usecase. So, we delete constructor arguments and our usecase code after the change looks like this:

class SnippetsUseCase
  attr_reader :snippets

  def initialize
    @snippets = []
  end

  def user_pushes(snippet)
    snippets << snippet
  end

  def user_pushed(snippet); end

  def user_fails_to_push(snippet, pushing)
    snippets.delete(snippet)
  end
end

Notice the empty method user_pushed- it’s perfectly fine, we’re maintaining it only to provide a join point for our solution. You’ll often see empty methods in code written in AOP paradigm. In my code, with a bit of metaprogramming, I turn it into a helper, so it becomes something like:

join_point :user_pushed

Now we can test this unit class without any stubbing or mocking. Extremely convenient, isn’t it?

Afterwards, we have to provide aspect code to link with our use case. So, we create SnippetsUseCaseGlue class:

require 'aquarium'

class SnippetsUseCaseGlue
  attr_reader :usecase, :repository, :logger

  include Aquarium::Aspects

  def initialize(usecase, repository, logger)
    @usecase = usecase
    @repository = repository
    @logger = logger
  end

  def inject!
    Aspect.new(:after, object: usecase, calls_to: :user_pushes) do |jp, obj, snippet|
      repository.push(snippet, 
                      success: usecase.method(:user_pushed),
                      failure: usecase.method(:user_fails_to_push))
    end

    Aspect.new(:after, object: usecase, calls_to: :user_pushed) do |jp, object, snippet|
      logger.info("Successfully pushed: #{snippet.name} (#{snippet.language})")
    end

    Aspect.new(:after, object: usecase, calls_to: :user_fails_to_push) do |jp, object, snippet, pushing|
      logger.error "Failed to push our snippet: #{pushing.error}"
    end
  end
end

Inside the advice block, we have a lot of info - including very broad info about join point context (jp), called object and all arguments of the invoked method.

After that, we can use it in an application like this:

class Application
  def initialize
    @snippets            = SnippetsUseCase.new
    @snippets_repository = SnippetsRepository.new
    @logger              = Logger.new
    @snippets_glue       = SnippetsUseCaseGlue.new(@snippets, 
                                                   @snippets_repository, 
                                                   @logger)

    @snippets_glue.inject!

    # rest of logic
  end
end

And that’s it. Now our use case is a pure domain object, without even knowing it’s connected with some kind of persistence and logging layer. We’ve eliminated aspects knowledge from this object.

Further read:

Of course, it’s a very basic use case of aspect oriented programming. You can be interested in expanding your knowledge about it and these are my suggestions:

  • Ports and adapters (hexagonal) design - one of the most useful usecases of using AOP to structure your code wisely. Use of AOP here is not needed, but it’s very convenient and in Arkency we favor to glue things up with advice instead of evented model, where we push and receive events.
  • aquarium gem homepage - aquarium is a quite powerful (for example, you can create your own join points) library and you can learn about more advanced topics here. It might be worth noting, though, that aquarium doesn’t work well with threads.
  • YouAreDaBomb - AOP library that Arkency uses for JavaScript code. Extremely simple and useful for web developers.
  • AOP inventor paper about it, with a extremely shocking use case - Kiczales’ academic paper about AOP. His use case of AOP to improve efficiency of his app without making it unmaintainable is… interesting.

Summary

Aspect-oriented programming is fixing the problem with polluting pure logic objects with technical context of our applications. Its usecases are far broader - one of the most fascinating usecase of AOP with a huge ‘wow factor’ is linked in the ‘Further Read’ section. Be sure to check it out!

We’re using AOP to separate these aspects in chillout - and we’re very happy about it. What’s more, when developing single-page apps in Arkency we embrace AOP when designing in hexagonal architecture. It performing very nice - just try it and your application design will improve.

Someone can argue:

It’s not an improvement at all. You pushed the knowledge about logger and persistence to another object. I can achieve it without AOP!

Sure you can. It’s a very simple usecase of AOP. But we treat our glues as a configuration part, not the logic part of our apps. The next refactor I would do in this code is to abstract persistence and logging objects in some kind of adapter thing - making our code a bit more ‘hexagonal’ ;). Glues should not contain any logic at all.

I’m very interested in your thoughts on AOP. Have you done any projects embracing AOP? What were your use cases? Do you think it’s a good idea at all?

You might also like