Hyperloop Architecture

Hyperloop's primary goal is to allow you to enjoy quickly building modern interactive web applications.

You have full access to the entire Rails ecosystem and universe of front-end JavaScript libraries like React and jQuery - all using one great language - Ruby.

Hyperloop lets you write code that is directed toward solving the user's needs in the most straightforward manner, without redundant code, unnecessary APIs, or artificial separation between client and server.

Our (isomorphic) framework consists of Components, Operations, Models, Policies, and Stores. This structure is analogous to and replaces the older MVC architecture, but with a more logical and finer grained division of labor.

Hyperloop comps diagram
Hypercomponents
Components describe how the UI will display the current application state and how it will handle user actions. Using React, Components automatically rerender parts of the display as state changes due to local or remote activities.
Hyperoperations
Operations encapsulate business logic. In a traditional MVC architecture, Operations end up either in Controllers, Models or some other secondary construct such as service objects, helpers, or concerns. Here they are first class objects. Operations orchestrate the interactions between Components, external services and Stores.
Hypermodels
Your ActiveRecord Models are available in your Isomorphic code. Components, Operations, and Stores have CRUD access to your server side Models, using the standard ActiveRecord API. Amazingly, we automatically synchronize data between connected clients.
Hyperpolicies
Policies keep authorization logic out of Models, and Operations, and also allows the isomorphic transport mechanism to know what and when to communicate between client and server.
Hyperstores
Stores hold the local application state. Stores are Ruby classes that keep the dynamic parts of the state in special state variables. We use Stores to share state between Components.

Benefits of COMPS Architecture

  • Encapsulates functionality for clean, predictable, testable code
  • One language - Ruby everywhere - reduces complexity and lets developers build solutions quickly
  • Encourages client-side execution for distributed processing and a rich interactive user experience
  • Full power of Rails, React and the entire JavaScript universe
  • Transparent, automatic and secure client-server communication built into Models and Operations

COMPS Overview

Hypercomponents
Components

You build your UI using React Components described as Ruby classes. Within your Components, you can display other components, change state, access models, or communicate with third party APIs. Typically you will want to use Operations to encapsulate these activities. Here is a simple example using our AddBookToBasket operation.

class BookList < Hyperloop::Component
  # Display each book in our catalog unless it's already in the cart basket.
  # When the user clicks on a book, add it to the Basket.
  render(UL) do
    Book.all.each do |book|
      LI { "Add #{book.name}" }.on(:click) do
        AddBookToBasket(book: book)
      end unless acting_user.basket.include? book
    end
  end
end

Notice how our component directly scopes the Book model and reads the name attribute. Models are dynamically synchronized to all connected and authorized clients using ActionCable, pusher.com or polling. The synchronization is completely automatic and magical to behold.

Hyperstores
Stores

Stores are where the state of your Application lives.

Anything but a completely static web page will have dynamic states that change because of user inputs, the passage of time, or other external events.

Stores are Ruby classes that keep the dynamic parts of the state in special state variables

For example here is Store that keeps track of time at a given location:

class WorldClock < HyperStore
  # Keep track of the time at multiple locations
  attr_reader :name
  attr_reader :lattitude
  attr_reader :longitude
  attr_reader :time_zone_offset

  def current_time
    WorldClock.gmt+time_zone_offset
  end

  def initialize(name, lattitude, longitude, time_zone_offset)
    @name, @lattitude, @longitude, @time_zone_offset =
      [name, lattitude, longitude, time_zone_offset]
  end

  def WorldClock.gmt
    unless state.gmt
      every(1) { mutate.gmt Time.now.gmt }
      mutate.gmt Time.now
    end
    state.gmt
  end
end

Now we can create a clock and post the time to the console every minute like this:

new_york = WorldClock.new('New York', 40.7128, -74.0059, 5.hours)
every(1.minute) { puts new_york.current_time }

But because it is a Reactive Store we can also say this:

# assume we have a div with id='new-york' some place in our code
Element['div#new-york'].render do
  "The time in #{new_york.name} is #{new_york.current_time}"
end

This will automatically rerender the contents of the 'new-york' DIV whenever the store changes

Hypermodels
Models

Hyperloop uses Rails ActiveRecord for data persistence. This allows easy integration with existing Rails apps. Hyperloop Models are implemented in the HyperModel Gem.

Hyperloop gives you full access to the ActiveRecord models on the client or the server which means we can use the models directly within our Components without needing the abstraction of an API:

class BookList < Hyperloop::Component
  # Display each book in the catalog
  render(UL) do
    Book.in_catalog.each do |book|
      LI { book.name }
    end
  end
end

Changes made to Models on a client or server are automatically synchronized to all other authorized connected clients using ActionCable, pusher.com or polling. The synchronization is completely automatic and magical to behold.

Hyperoperations
Operations

Hyperloop recommends that only scopes, relations, and validations are described in Model classes. All business logic can be encapsulated in reusable isomorphic Operations that do not complicate your Models or Components. Each Operation is a self-contained piece of logic that does one simple thing.

class AddToActingUsersWatchList < Hyperloop::Operation
  # Add a book to the current acting_user's watch list, and
  # send an initial email about the book.

  param :book, type: Book
  # Operations have access to the current 'acting_user'
  # so we do not need to pass it as a parameter.

  step do
    return if acting_user.watch_list.include? params.book
    WatchListMailer.new_book_email\
      WatchList.create(user: acting_user,  book: params.book)
  end
end

Pretty simple. Typically code like this might be found in a controller which makes it hard to reuse or in a model which makes maintenance difficult when business logic changes. Placing it in its own Operation makes it easy to maintain, reuse and test.

Of course, Operations can invoke other Operations:

class AddBookToBasket < Hyperloop::Operation
  # Add a book to the basket and add to users watchlist
  param :book, type: Book

  step do
    acting_user.basket << book
    AddToActingUsersWatchList(book: params.book)
  end
end

Hyperpolicies
Policies

While communication between the client and server is automatic it does need to be authorized. This is accomplished by regulations which can be grouped into pundit style Policy classes. This allows your access rules to be described separately from your Models and Operations.

class BookPolicy
  regulate_broadcast do |policy|
    # allow the entire application to see all book attributes
    # except the 'unit_cost'.
    policy.send_all_but(:unit_cost).to(Application)
  end
  # but only acting_user's who are admins can make changes to Books
  allow_change(on: [:create, :update]) { acting_user.admin? }
end

class OperationPolicy
  # We need AddToActingUsersWatchList to execute on the server but
  # it can be invoked from the client if there is a logged in user.
  AddToActingUsersWatchList.execute_on_server { acting_user }
end

Pragmatic Thinking

Hyperloop provides all the architectural constructs you need for a well designed, modern web application but we are not strongly opinionated as to how you use it. We would like you to find your own way through this architecture, to use the parts that make the most sense for your application and coding style.

Here are a few pragmatic pointers which might help you:

  • If a state is only mutated inside of a Component then leave it as a state in the Component. For example, a state that is tracking the current value of some input.
  • Otherwise, if it is a single application-wide state object (like a cart), then use a Store, and group Operations in the Store's namespace.
  • Otherwise, if you are going to have instances of the state (like you have a Store that manages a random feed of objects like tweets, GitHub users etc) then use a Store and add accessor and mutators to the store's API. Those methods may need Operations (which can be name spaced inside the store) to deal with APIs, server side code etc.

Why?

  • because its simple... don't use Stores, Operations, or anything else if you don't need to. Don't unnecessarily expose the internals of a Component.
  • because you want to centralize Stores, and using Operations to mutate the store provides a consistent interface to the outside. If mutating the Store becomes more complex the power of the Operation can be used without an API change.
  • because in this case trying to use Operations becomes more cumbersome than its worth. You would have to pass the instance variable around to the Operation and simple things like tweet_feed.next! and tweet_feed.avatar would look like StreamStore::Next(feed: tweet_feed) and StreamStore.avatar(tweet_feed).
  • So in this case, since you are building more complex Store it is reasonable to hide the Operation (which will still exist) inside the StreamStore#next! method