Skip to content

Ks/effects v2 #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,23 @@ the time of application boot.
- Are we testing the wrong abstraction?
- Should we go with a [hand crafted mock](test/support/mock_service.ex)?
- Are there any other options to solve this?


## Interpreter

This branch removes all mocks, and the behaviour. Now we just have a single
definition of our service `Example.Service`. This instantly makes the code
simpler to read and follow. In order to connect our worker to the service we
add a new layer of indirection called the interpreter `Example.Interpreter`.
Instead of calling the service directly, the worker creates a description of
the side effect `Example.Effect` and passes that to the interpreter. In
production the default interpreter is configured to translate the effect into a
real world effect, and in the test environment we replace the default
interpreter with a test interpreter `Test.Interpreter` which simply returns
the effect - and we can simply assert the shape of the effect is correct.

So whats missing here, what tests are we missing?

some research links:
- https://github.com/yunmikun2/free_ast/blob/master/lib/free_ast.ex
- https://github.com/slogsdon/elixir-control/
37 changes: 37 additions & 0 deletions TALK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Scalable software patterns with Monads

This project provides a contrived example of when using Mox can make things
difficult. Besides being difficult to test, this approach to writing software
also suffers from scalability.

Let's walk through a simple example:

You have some code which depends on external services, let's imagine you need
to make an http request which consumes some data, processes that data and
writes to a cache. Once the cache has been updated the service then connects
to an amqp service and processes messages using data from the cache, eventually
writing the results to a database.

The naive approach is to write a simple initialisation:

1. Make http request
2. Process response
3. Update cache
4. Subscribe to AMQP
5. Process incoming messages
6. Read from cache
7. Write to database

In order to test this code we could replace some parts with mocks. One way we
could do this is to identify the noun's in our system. Let's call them services
and see where we get to:

1. Make http request (http service)
2. Process response (http processing service)
3. Update cache (cache service)
4. Subscribe and consumer AMQP (consumer service)
5. Process incoming messages (amqp processing service)
6. Read from cache (cache service)
7. Write to database (database service)

To be continued...
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
use Mix.Config

config :example, service: Example.MockService
config :example, interpretor: Test.Interpreter
13 changes: 0 additions & 13 deletions lib/example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,4 @@ defmodule Example do
@moduledoc """
Documentation for Example.
"""

@doc """
Hello world.

## Examples

iex> Example.hello()
:world

"""
def hello do
:world
end
end
7 changes: 0 additions & 7 deletions lib/example/default_service.ex

This file was deleted.

34 changes: 34 additions & 0 deletions lib/example/effect.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule Example.Effect do
@moduledoc """
Module for describing side effects

Rather than litter the code with real side effects, why not place
descriptions of those side effects. Code is data, data is code, let the
side effects become the data.
"""

alias __MODULE__

@type t :: %{m: module, f: atom, a: [any]}

defstruct [:m, :f, :a]

def new(m, f, a \\ []), do: %__MODULE__{m: m, f: f, a: List.wrap(a)}

defmacro effect(block) do
{{_, _, [{_, _, mod}, f]}, _, args} = block

m = Module.concat(mod)

quote bind_quoted: [m: m, f: f, args: args] do
Effect.new(m, f, args)
end
end

defmacro __using__(_opts \\ []) do
quote do
require Effect
import Effect
end
end
end
30 changes: 30 additions & 0 deletions lib/example/interpreter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Example.Interpreter do
alias Example.Effect
require Logger

# def run(%Effect{m: m, f: f, a: a} = _effect) do
# timeout = 5000
#
# task =
# Task.async(fn ->
# apply(m, f, a)
# end)
#
# case Task.yield(task, timeout) || Task.shutdown(task) do
# {:ok, result} ->
# result
#
# nil ->
# Logger.warn("Failed to get a result in #{timeout}ms")
# nil
# end
# end

def run(%Effect{m: m, f: f, a: a} = _effect) do
apply(m, f, a)
end

# def run(%Effect{m: m, f: f, a: a} = _effect) do
# IO.inspect effect, label: "real effect"
# end
end
12 changes: 12 additions & 0 deletions lib/example/product.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule Product do
@default_name "default"

@type t :: %{
id: integer,
name: binary
}

defstruct [:id, :name]

def new(id), do: %__MODULE__{id: id, name: @default_name}
end
3 changes: 0 additions & 3 deletions lib/example/service_behaviour.ex

This file was deleted.

28 changes: 28 additions & 0 deletions lib/example/services/database.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Example.Services.Database do
@moduledoc """
Database service provide way to save and retreive values from a persistant
store
"""

@spec get(integer) :: Product.t() | nil
def get(id) do
Process.sleep(1000)

case id do
1 ->
Product.new(1)

_ ->
nil
end
end

@spec update(Product.t() | any) :: :ok | :error
def update(%Product{id: id}) do
if id == 1 do
:ok
else
:error
end
end
end
17 changes: 17 additions & 0 deletions lib/example/services/http.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule Example.Services.Http do
@moduledoc """
Web service
"""

Application.ensure_all_started(:inets)
Application.ensure_all_started(:ssl)

@spec fetch() :: {:ok, any, any} | {:error, any}
def fetch() do
# Now we can make request:
case :httpc.request(:get, {'http://www.mocky.io/v2/5e5a23e730000071001f0ade', []}, [], []) do
{:ok, {{'HTTP/1.1', 200, 'OK'}, headers, body}} -> {:ok, headers, body}
_ -> {:error, "Request failed"}
end
end
end
13 changes: 13 additions & 0 deletions lib/example/services/service_a.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Example.Services.ServiceA do
@moduledoc """
ServiceA provides a way to request data from external webserver

The performance of ServiceA is somewhat sketchy... we don't know how long it
takes to get data, sometimes the data is available, sometimes it's not, and
sometimes the service doesn't even respond.
"""
def foo() do
Process.sleep(1000)
"real service says foo"
end
end
20 changes: 20 additions & 0 deletions lib/example/services/service_b.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule Example.Services.ServiceB do
@moduledoc """
ServiceB translates the data from ServiceA into something you can show to
your users - but only if you give it the right information.

Failure to provide the right info means it gets stuck for a few minutes,
persumably searching some database or something, meanwhile, you're left
hanging.
"""
def bar(args) do
case args do
"real service says foo" ->
"you get the cookie, well done"

_ ->
Process.sleep(1000)
"Sorry but you need to give me something..."
end
end
end
77 changes: 53 additions & 24 deletions lib/example/worker.ex
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
defmodule Example.Worker do
use GenServer

alias Example.DefaultService
use Example.Effect

# When using ElixirLS, defining the service at compile time will result in an
# error because ElixirLS always compiles using MIX_ENV=test which mean @service
# will always be set to MockService, which does not have `foo/0`
# @service Application.get_env(:example, :service, DefaultService)
# @service DefaultService

def service() do
Application.get_env(:example, :service, DefaultService)
end
@interpretor Application.get_env(:example, :interpretor, Example.Interpreter)

def start_link(init_arg \\ []) do
GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)
Expand All @@ -21,31 +13,68 @@ defmodule Example.Worker do
GenServer.call(__MODULE__, :get_foo)
end

def get_bar() do
GenServer.call(__MODULE__, :get_bar)
end

def init(_init_arg) do
initial_state = "no foo for you"
{:ok, initial_state, {:continue, :get_foo_from_service}}
end

def handle_continue(:get_foo_from_service, _state) do
# And here lies the problem. We want to call our service to get
# whatever inital state it provides, but in doing so, we break
# in the test environment because the MockService doesn't have
# a function called `foo/0` until it can be defined in the expects
# block within the test - by that time, this code has already
# been executed because this GenServer is part of the staticly
# defined supervision tree in `application.ex`.

value_of_foo =
if function_exported?(service(), :foo, 0) do
service().foo()
else
"#{inspect(service())} does not support foo"
end
# SIDE EFFECT HERE!!!
# value_of_foo = @interpretor.run(Effect.new(Services.ServiceA, :foo))
s1 = effect(Example.Services.ServiceA.foo())
value_of_foo = @interpretor.run(s1)

{:noreply, value_of_foo}
end

def handle_call(:get_foo, _from, state) do
{:reply, state, state}
end

def handle_call(:get_bar, _from, state) do
# SIDE EFFECT HERE!!!
# value_of_bar = @interpretor.run(Effect.new(Services.ServiceB, :bar, state))
s1 = effect(Example.Services.ServiceB.bar(state))

value_of_bar = @interpretor.run(s1)

{:reply, value_of_bar, state}
end

# Non-GenServer functions

def update_product(id, new_name) do
# SIDE EFFECT!
# product = Services.Database.get(id)
# product = @interpretor.run(Effect.new(Services.Database, :get, id))
s1 = effect(Example.Services.Database.get(id))

# TODO: Running s1 will return either a Product, or a nil. The existing
# side effect doesn't do anything to acknowledge this fact.
product = @interpretor.run(s1)

# Functional core!
updated_product = %Product{product | name: new_name}

# SIDE EFFECT!
# Services.Database.update(updated_product)
s2 = effect(Example.Services.Database.update(updated_product))

# _product = @interpretor.run(Effect.new(Services.Database, :update, updated_product))
_product = @interpretor.run(s2)
end

def get_data() do
s1 = effect(Example.Services.Http.fetch())
i1 = Application.get_env(:example, :interpretor2, Example.Interpreter)

case i1.run(s1) do
{:ok, _headers, body} -> body
{:error, reason} -> reason
end
end
end
8 changes: 0 additions & 8 deletions test/example_test.exs

This file was deleted.

16 changes: 16 additions & 0 deletions test/support/interpreter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Test.Interpreter do
alias Example.Effect

def run(%Effect{a: [1], f: :get, m: Example.Services.Database}) do
Product.new(1)
end

def run(%Effect{a: _, f: :get, m: Example.Services.Database}) do
nil
end

def run(%Effect{} = effect) do
# IO.inspect(effect, label: "test effect")
effect
end
end
11 changes: 0 additions & 11 deletions test/support/mock_service.ex

This file was deleted.

Loading