Last active
October 27, 2015 19:41
-
-
Save booch/3b32ec5a3557c9e6ccbf to your computer and use it in GitHub Desktop.
MORF (generic hexagonal Ruby framework) ideas
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# MORF - My Own Ruby Framework # | |
# This is based on Uncle Bob's talk at Midwest Ruby 2011, | |
# [Architecture the Lost Years](https://www.youtube.com/watch?v=WpkDN78P884), | |
# as well as his article on [Clean Architecture](https://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html). | |
# Also inspired by Gary Bernhardt's [Boundaries](https://www.youtube.com/watch?v=yTkzNHF6rMs) talk, | |
# and lots of other immutability (functional core) ideas being used within OOP programming (like Piotr Solnica's work). | |
ENV['DB_URL'] = 'memory:morf_example' | |
module MORF | |
def self.Interaction(*args) | |
Interaction.tap do |modul| | |
# modul.extend Blah if args[:blah] | |
end | |
end | |
def self.DomainObject(*args) | |
DomainObject | |
end | |
def self.Entity(*args) | |
Entity | |
end | |
def self.Repo(*args) | |
Repo | |
end | |
end | |
module MORF::Web | |
def self.Controller(*args) | |
Controller | |
end | |
end | |
require "riposte" | |
module MORF | |
module Interaction | |
include Riposte::Helper | |
def initialize(datastore: = nil) | |
@datastore = datastore | |
end | |
private | |
def datastore | |
@datastore ||= ENV["DB_URL"] | |
end | |
end | |
end | |
module MORF | |
# A domain object encodes business logic in the problem domain space. This is typically not specific to the application. | |
module DomainObject | |
def self.included(base) | |
base.class_eval do | |
def self.attribute(name, type=String, options={}) | |
attributes << Attribute.new(name.to_sym, type, options) | |
attr_reader name | |
end | |
def self.attributes | |
@attributes ||= [] | |
end | |
def self.copy(original, attribute_values = {}) | |
self.new(original.attributes.merge(attribute_values)) | |
end | |
end | |
end | |
def initialize(attribute_values = {}) | |
attribute_values.each do |k,v| | |
# TODO: Perform any necessary type coercions. | |
instance_variable_set("@#{k}".to_sym, v) if attributes.map(&:name).include?(k.to_sym) | |
# TODO: What should we do about any values passed in for undefined attributes? | |
end | |
# TODO: Ensure all required attributes have been set. | |
# TODO: Perform any necessary validations. | |
end | |
# Return a copy of the object, with updated attributes. | |
def copy(attribute_values={}) | |
self.class.copy(self, attribute_values) | |
end | |
def attributes | |
self.class.attributes | |
end | |
# TODO: Need to be able to compare domain objects to each other. | |
class Attribute | |
attr_reader :name, :type, :options | |
def initialize(name, type=String, options={}) | |
@name = name | |
@type = type | |
@options = options | |
end | |
end | |
end | |
end | |
module MORF | |
# An Entity is a DomainObject with an ID. | |
# Entities are immutable by default. | |
module Entity | |
def self.included(base) | |
base.class_eval do | |
include DomainObject | |
attribute :id, Integer | |
end | |
end | |
end | |
end | |
module MORF | |
module Repo | |
def initialize(datastore:) | |
end | |
def insert(entity) | |
# TODO: Grab entity's attributes, shove them into data store. | |
end | |
def get(id) | |
# TODO: Grab entity from datastore by its ID. | |
end | |
def all | |
# TODO: Grab all entities from datastore. | |
end | |
def find(*criteria) | |
# TODO: Grab all entities from datastore that match the criteria. | |
end | |
end | |
end | |
module MORF::Web | |
class Response | |
attr_accessor :code | |
attr_accessor :headers | |
attr_accessor :body | |
end | |
module Rendering | |
def render | |
# TODO: Return a Response. | |
end | |
def redirect(to:) | |
Response.new(code: 303, headers: {Location: to}, body: '') | |
end | |
end | |
module Controller | |
include Rendering | |
attr_reader :request | |
def initialize(request:) | |
@request = request | |
end | |
end | |
end | |
# Interaction / Interactions (Uncle Bob calls these Interactors or Use Cases) | |
# Other possible names: Transaction, Command, Function, Feature, Service Object. | |
# "Function" is bad, because that conflicts with functions in programming languages. | |
# "Transaction" is bad, because that conflicts with DB transactions. | |
# "Command" is questionable, because the GoF command requires the client code to know about the entities involved. | |
# However, Russ Olson's "Design Patterns in Ruby" ignores that. | |
#require "morf" | |
#require "repo/pact" | |
#require "entitity/pact" | |
class CreatePact | |
include MORF.Interaction | |
def call(description:, deadline:) | |
pact = Pact.new(description: description, deadline: deadline) | |
repo.insert(pact) | |
end | |
private | |
# Should probably move this to MORF::Interaction, but that would require determining the repo class. | |
def repo | |
@repo ||= PactRepo.new(datastore: datastore) | |
end | |
end | |
# Entity (Model) | |
#require "morf" | |
class Pact | |
include MORF.Entity | |
attribute :description, String | |
attribute :deadline, DateTime | |
end | |
# Repo (might be a thin layer on top of Sequel) | |
#require "morf" | |
#require "entity/pact" | |
class PactRepo | |
include MORF.Repo(entity_class: Pact, table: "pacts") | |
end | |
# Web Page Controller | |
#require "morf/web" | |
class Web::PactController | |
include MORF::Web.Controller | |
# TODO: Need to handle URL paths that include IDs, and collections versus items. | |
def post(params) | |
# FIXME: I don't like this at all. The `new` and `call` seem superfluous. | |
CreatePact.new.call(description: params[:description], deadline: params[:deadline]) do |on| | |
on.display { |pact| render "pact/create", pact: PactPresentation.new(pact) } | |
on.permission_denied { render "pact/permission_denied" } | |
end | |
# TODO: Return a Response object (render and redirect return such an object). | |
# Or a String, that will be turned into a Response object. | |
end | |
end | |
# Web Front Controller (maybe) | |
# Form Object | |
# Validations | |
# Authentication | |
# Presentation | |
# View | |
### Testing our example Interation. | |
# Note that we can use `.()` as a shorthand for `.call()`. | |
CreatePact.new.(description: 'Blah', deadline: Date.today) | |
### Considerations | |
# Authentication (interactor requiring something from the controller) | |
# Authorization (interactor) | |
# Web security (CSRF, XSS, CORS) | |
# Web caching | |
# Web hypermedia (multiple representations for a given resource) | |
# Server push (Server-sent events, Web Sockets, HTTP 2) | |
# Optimistic locking (harder with immutable entities) | |
# Unit of Work pattern is important | |
# Need to possibly worry about wrapping DB accesses within transactions | |
# Should consider interactors that are combinations of other interactors | |
# Should the repo for an entity named `Person` be called `PersonRepo`, `People`, `Person::Repo`, or `Repo::People`? | |
# I think I prefer Repo::People. | |
### Differences from Uncle Bob | |
# Still can't tell from top level what the app is for | |
# Some of the names have been changed | |
# I think passing entities around in Ruby is fine, as long as entities are immutable | |
# Because then the entity attributes can be lazy, and not generated if they're not needed | |
# Because hashes are stringly-typed, so don't prevent trying to access the wrong attribute | |
# Repository pattern | |
# But I have the repo depend on the entity it contains, in contrast to https://subvisual.co/blog/posts/20-clean-architecture | |
# One reason: I want to be able to have scopes that depend on info contained in the entity | |
# Not sure my use of a DB/persistence URL is sufficiently abstracting away that dependecy direction | |
# TO CONSIDER: How do we DRY up the various actions that all act on the same kind of object? | |
# Perhaps we can do some of what Decent Exposure does. | |
class OrderInteractions | |
def initialize(repository: = nil) | |
@repository = repository | |
end | |
def list | |
end | |
def create | |
end | |
def get(id, &block) | |
order = repository.find(id) | |
# Think I'd prefer if this was `respond_with` instead of `react_to`. | |
react_to :display, order, &block | |
rescue RecordNotFound | |
react_to :not_found, id, &block | |
rescue Exception => exception | |
react_to :exception, exception, id, &block | |
end | |
def update | |
end | |
def delete | |
end | |
private | |
def repository | |
@repository ||= OrderRepo.new | |
end | |
def order(id) | |
end | |
def orders | |
end | |
end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment