Domain Driven Design diagram for Umbrella app

Hi all!

I am working on umbrella app now - 4 apps in it.

Being impressed with Controller Control: Designing Domains for Web Applications - Gary Rennie and Episode @057: Educating in Elixir with Dave Thomas @pragdave I have started to think about my application design.

I have started to learn these books:
Martin Fowler - Patterns of Enterprise Application Architecture
InfoQ: Domain Driven Design Quickly

Also, @Gazler recommended:
ElixirConf 2016 - Building Umbrella Project by Wojtek Mach and
github:acme_bank by @wojtekmach

Based on it, I have made a diagram:

How can I improve it?

14 Likes

Well, I have a very close setup to yours, the only difference is the β€œinfrastructure layer” you have and I don’t. In my case, the plugs stay on the application layer (because they have web specific logic, like session control and etc), and the service and the repo go to the domain layer, which does not have any kind of web logic, just business logic.

I’m happy with my structure for now, and it’s a pretty clear separation of concerns. My next step, I guess, is to separate the domain layer in more pieces, by resources and features and not by a specific layer itself. Something I learned with CBRA (Component Based Rails Applications) your application is a big box, and big boxes tend to become a mess if you don’t separate into mor boxes, and label them correctly.

4 Likes

Sounds like you are talking about bounded contexts. Identifying and maintaining the correct bounded contexts within a monolith is supposed to give some of the benefits of microservices without taking on the inherent overhead (though in the end more discipline is required). The interesting thing is that applying DRY across context boundaries can result in a β€œmess of coupling”.

David Dawson:

Also InfoQ: Don’t Share Code Between Microservices (2015-Jan-25).

4 Likes

Yep! That’s what I was talking about!

2 Likes

Would be cool if you in the end shared this architecture as open source project. :wink:

4 Likes

Sure, but first of all we should improve diagram :slight_smile:

2 Likes

I had a similar plan going.

But I’m wondering where to put both email delivery or file processing / uploading to s3? ACME bank has messaging as a separate module, but wojtekmach mentioned in another post that that repo is to demonstrate what’s possible more than what you necessarily should do. So just wanted to see the general consensus.

File processing / uploading - Depends on checking user permissions (biz logic) and saving newly created s3 URL to the db (biz logic), but the details of where to upload the file and how to process/compress the file are not super tightly tied to biz logic, right? I don’t know. But I’m kind of leaning to keeping file processing in the biz logic part of the app.

Messaging/transactional emails - Biz logic says which emails to send to whom and when… But it doesn’t necessarily need to know whether a message was successfully sent. It could theoretically just tell the messaging app to send a message, then forget about it. So I’m leaning toward that being a separate app.

2 Likes

I’m trying out the following structure

umbrella
β”œβ”€β”€ core (Elixir)
β”œβ”€β”€ web (Phoenix)
└── api (GraphQL)
β”œβ”€β”€ config
β”‚   β”œβ”€β”€ config.exs
β”‚   β”œβ”€β”€ dev.exs
β”‚   β”œβ”€β”€ prod.exs
β”‚   └── test.exs
β”œβ”€β”€ lib
β”‚   β”œβ”€β”€ core
β”‚   β”‚   β”œβ”€β”€ checkout
β”‚   β”‚   β”‚   β”œβ”€β”€ (...)
β”‚   β”‚   β”œβ”€β”€ account
β”‚   β”‚   β”‚   β”œβ”€β”€ authorizers
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop_authorizer.ex
β”‚   β”‚   β”‚   β”‚   └── user_authorizer.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ models
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ domain.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ tax_setting.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ theme.ex
β”‚   β”‚   β”‚   β”‚   └── user.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ repositories
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization_repository.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_repository.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop_repository.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ theme_repository.ex
β”‚   β”‚   β”‚   β”‚   └── user_repository.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ organization_service.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_service.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ shop_service.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ theme_service.ex
β”‚   β”‚   β”‚   └── user_service.ex
β”‚   β”‚   β”œβ”€β”€ inventory
β”‚   β”‚   β”‚   β”œβ”€β”€ authorizers
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ permalink_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ variant_authorizer.ex
β”‚   β”‚   β”‚   β”‚   └── product_authorizer.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ models
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ image.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ permalink.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ price.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ product.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ stock.ex
β”‚   β”‚   β”‚   β”‚   └── variant.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ repositories
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection_repository.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ permalink_repository.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ variant_repository.ex
β”‚   β”‚   β”‚   β”‚   └── product_repository.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ uploaders
β”‚   β”‚   β”‚   β”‚   └── image_uploader.ex
β”‚   β”‚   β”‚   β”œβ”€β”€ collection_service.ex
β”‚   β”‚   β”‚   │── permalink_service.ex
β”‚   β”‚   β”‚   │── variant_service.ex
β”‚   β”‚   β”‚   └── product_service.ex
β”‚   β”‚   β”œβ”€β”€ relation
β”‚   β”‚   β”‚   └── models
β”‚   β”‚   β”‚       β”œβ”€β”€ collection_permalink.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_collection.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_image.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_permalink.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_price.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_shop.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ product_stock.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ shop_organization.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ user_organization.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ variant_image.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ variant_permalink.ex
β”‚   β”‚   β”‚       β”œβ”€β”€ variant_price.ex
β”‚   β”‚   β”‚       └── variant_stock.ex
β”‚   β”‚   β”œβ”€β”€ application.ex
β”‚   β”‚   β”œβ”€β”€ authorizer.ex
β”‚   β”‚   β”œβ”€β”€ definitions.ex
β”‚   β”‚   └── repo.ex
β”‚   └── core.ex
β”œβ”€β”€ priv
β”‚   └── repo
β”‚       β”œβ”€β”€ migrations
β”‚       β”‚   β”œβ”€β”€ (...)
β”‚       └── seeds.exs
β”œβ”€β”€ test
β”‚   └── (...)
β”œβ”€β”€ README.md
└── mix.exs

In web and api I use the core package like:

{:ok, shop} = MyApp.Core.Account.ShopService.get(1)
{:ok, shop} = MyApp.Core.Account.ShopService.update(shop, params)

defmodule MyApp.Core.Account.ShopService do
  alias MyApp.Core.Account.{ShopAuthorizer,ShopRepository,Shop}

  def update(%Shop{} = shop, params) when is_map(params) do
    with :ok <- ShopAuthorizer.authorize(:update, shop),
      do: ShopRepository.update(shop, params)
  end
end
12 Likes

I have mail delivery in business logic - it is single point of sending emails in my umbrella application.
If I will change UI or add additional I will still have this functional.

4 Likes

Interesting! I hope you don’t mind, but I have a bunch of questions =)

It seems like your app must handle p2p payments and/or payment to use the app. So you handle the payment processing in the core app? Like, use stripe or something within the core app, or did you make a separate app to handle that?

I would imagine webhooks come in via your payment processor from time to time? Do you basically forward those directly to your core app?

When a request comes in via your api, where do you put the context plug for graphql? Like, the graphql app needs the context, which is given by the user account’s tokens grabbed from the core app, but is available via the connection in the web app? How did you put that bit together?

How did you end up handling file uploads with graphql? Are you posting to a separate non-graphql route for uploads or setting it in context?

Do your graphql resolvers ever call changesets directly or do they only hit up your services?

Are your graphql resolvers responsible for checking authorizations, or do your services ask for the authorization first, and the graphql resolver just calls the service?

Thanks!

3 Likes

Ask away! Small sidenote though, I’m still building it :wink:

It seems like your app must handle p2p payments and/or payment to use the app. So you handle the payment processing in the core app? Like, use stripe or something within the core app, or did you make a separate app to handle that?

Not really sure about this one yet, I would like to put the general (CRUD) payment logic in the core app but I’m still thinking about this one (and fulfilment as well).

I would imagine webhooks come in via your payment processor from time to time? Do you basically forward those directly to your core app?

I think a small router will handle the forwarding / parsing of the webhooks to the core app.

When a request comes in via your api, where do you put the context plug for graphql? Like, the graphql app needs the context, which is given by the user account’s tokens grabbed from the core app, but is available via the connection in the web app? How did you put that bit together?

The api is an GraphQL, Plug and Cowboy app. Any plug that is needed is placed with the app itself. (same for web) Both use the core app to check the credentials and retrieve the user.

How did you end up handling file uploads with graphql? Are you posting to a separate non-graphql route for uploads or setting it in context?

No sure yet, I’ve tested a couple of setups but haven’t found a good one yet.

Do your graphql resolvers ever call changesets directly or do they only hit up your services?

Only the services

Are your graphql resolvers responsible for checking authorizations, or do your services ask for the authorization first, and the graphql resolver just calls the service?

Guardian is used in both api and web to handle the authentication (token, session, etc). Comeonin is used in the core to handle the user related functions like creating the password hash and checking them.

4 Likes

Thanks! This is awesome to hear how you’re doing it with graphql.

So instead of the web app forwarding all requests to /api to the graphql app schema, you have 2 ports open, one for web, one for the graphql app?

I was thinking about 1 phoenix app for web concerns. But it seems there’s actually 2 concerns: displaying presentation layer… and routing with plugs/processing: webhooks, api, oauth, static content/spa routes.

Sounds like you’ll have 1 graphql app for the api, 1 web app for static content, 1 to be built for webhooks. And you may/may not build one for oauth. So you’ll have 3+ ports open I guess?

Out of curiousity, do your graphql resolvers check permissions, or do your services?

3 Likes

The web app is an Phoenix app that will handle the backend and the storefront. I might split it up into 2 separate apps but I’ll figure that out later.

The api app will handle all the api traffic and like web will use the core app for the persistence, lookup and other domain logic.

Webhooks will be built later but most likely will be a separate app with Plug and Cowboy.

Regarding ports, I’m using NGiNX to reverse proxy multiple domains to the apps.

server {
  listen       80;
  server_name  myapp.com;

  location / {
    proxy_pass http://127.0.0.1:5000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}
4 Likes

Pardon my ignorance but is there a technical reason for for adopting this type of organization:

inventory
        β”œβ”€β”€ authorizers
        β”‚   β”œβ”€β”€ collection_authorizer.ex
        β”‚   β”œβ”€β”€ permalink_authorizer.ex
        β”‚   β”œβ”€β”€ variant_authorizer.ex
        β”‚   └── product_authorizer.ex
        β”œβ”€β”€ models
        β”‚   β”œβ”€β”€ collection.ex
        β”‚   β”œβ”€β”€ image.ex
        β”‚   β”œβ”€β”€ permalink.ex
        β”‚   β”œβ”€β”€ price.ex
        β”‚   β”œβ”€β”€ product.ex
        β”‚   β”œβ”€β”€ stock.ex
        β”‚   └── variant.ex
        β”œβ”€β”€ repositories
        β”‚   β”œβ”€β”€ collection_repository.ex
        β”‚   β”œβ”€β”€ permalink_repository.ex
        β”‚   β”œβ”€β”€ variant_repository.ex
        β”‚   └── product_repository.ex
        β”œβ”€β”€ uploaders
        β”‚   └── image_uploader.ex
        β”œβ”€β”€ collection_service.ex
        │── permalink_service.ex
        │── variant_service.ex
        └── product_service.ex

rather than something like this

inventory
        β”œβ”€β”€ collection_service
        β”‚   β”œβ”€β”€ collection.ex
        β”‚   β”œβ”€β”€ collection_authorizer.ex
        β”‚   β”œβ”€β”€ collection_repository.ex
        β”‚   └── collection_service.ex
        │── permalink_service
        β”‚   β”œβ”€β”€ permalink.ex
        β”‚   β”œβ”€β”€ permalink_authorizer.ex
        β”‚   β”œβ”€β”€ variant_repository.ex
        β”‚   └── permalink_service.ex
        │── variant_service
        β”‚   β”œβ”€β”€ variant.ex
        β”‚   β”œβ”€β”€ variant_authorizer.ex
        β”‚   β”œβ”€β”€ variant_repository.ex
        β”‚   └── variant_service.ex
        └── product_service
            β”œβ”€β”€ product.ex
            β”œβ”€β”€ product_authorizer.ex
            β”œβ”€β”€ product_repository.ex
            └── product_service.ex

Seems the organizing principle is to put β€œlike” things into the same place rather than β€œputting everything to deal with subject area X” into the same place. One of the starting points described by the article that I linked to earlier - Bring clarity to your monolith with Bounded Contexts - is to β€œInvert folder structures into a flat domain-oriented grouping”.

The organizing principle of β€œputting like things into the same place” that ultimately is responsible for β€œa Rails app always looking like a Rails app” (and not communicating the actual intent behind the web app) is what inspired Architecture: The Lost Years (Ruby Midwest 2011 Keynote). That type of organization is rarely helpful in revealing the emerging (context) boundaries as the application matures (Evans, DDD p.48: β€œβ€¦crucial discoveries always emerge during the design/implementation effort”).

FYI: for anyone interested: Eric Evans: DDD Reference (PDF 2011)

8 Likes

It’s a matter of preference I guess. I’ll take a look at the links, thanks!

3 Likes

I like this approach a lot and I have similar situation with separation between Core (finishing), API (in parity with core) and Web (still to be done). It really helps the design phase not just implementation or maintenance.

4 Likes

Thanks for putting all these resources together in one post.

2 Likes

I’m trying a similar approach to the one you suggested and I’m liking it so far

.
β”œβ”€β”€ apps
β”‚   β”œβ”€β”€ core
β”‚   β”‚   β”œβ”€β”€ README.md
β”‚   β”‚   β”œβ”€β”€ config
β”‚   β”‚   β”‚   β”œβ”€β”€ config.exs
β”‚   β”‚   β”‚   β”œβ”€β”€ dev.exs
β”‚   β”‚   β”‚   β”œβ”€β”€ prod.exs
β”‚   β”‚   β”‚   └── test.exs
β”‚   β”‚   β”œβ”€β”€ lib
β”‚   β”‚   β”‚   β”œβ”€β”€ core
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ account
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ organization_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   └── organization_service.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   └── sales_channel_service.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ shop_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   └── shop_service.ex
β”‚   β”‚   β”‚   β”‚   β”‚   └── user
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ user.ex
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ user_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ user_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚       └── user_service.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ inventory
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ collection_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   └── collection_service.ex
β”‚   β”‚   β”‚   β”‚   β”‚   └── product
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ product.ex
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ product_authorizer.ex
β”‚   β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ product_repository.ex
β”‚   β”‚   β”‚   β”‚   β”‚       └── product_service.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ application.ex
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ publisher.ex
β”‚   β”‚   β”‚   β”‚   └── repo.ex
β”‚   β”‚   β”‚   └── core.ex
β”‚   β”‚   β”œβ”€β”€ mix.exs
β”‚   β”‚   β”œβ”€β”€ priv
β”‚   β”‚   β”‚   └── repo
β”‚   β”‚   β”‚       β”œβ”€β”€ migrations
β”‚   β”‚   β”‚       β”‚   └── ( ... )
β”‚   β”‚   β”‚       └── seeds.exs
β”‚   β”‚   └── test
β”‚   β”‚       β”œβ”€β”€ account
β”‚   β”‚       β”‚   β”œβ”€β”€ organization
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ organization_authorizer_test.exs
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ organization_repository_test.exs
β”‚   β”‚       β”‚   β”‚   └── organization_test.exs
β”‚   β”‚       β”‚   β”œβ”€β”€ sales_channel
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ sales_channel_authorizer_test.exs
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ sales_channel_repository_test.exs
β”‚   β”‚       β”‚   β”‚   └── sales_channel_test.exs
β”‚   β”‚       β”‚   β”œβ”€β”€ shop
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ shop_authorizer_test.exs
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ shop_repository_test.exs
β”‚   β”‚       β”‚   β”‚   └── shop_test.exs
β”‚   β”‚       β”‚   └── user
β”‚   β”‚       β”‚       β”œβ”€β”€ user_authorizer_test.exs
β”‚   β”‚       β”‚       β”œβ”€β”€ user_repository_test.exs
β”‚   β”‚       β”‚       └── user_test.exs
β”‚   β”‚       β”œβ”€β”€ inventory
β”‚   β”‚       β”‚   β”œβ”€β”€ collection
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ collection_authorizer_test.exs
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ collection_repository_test.exs
β”‚   β”‚       β”‚   β”‚   └── collection_test.exs
β”‚   β”‚       β”‚   └── product
β”‚   β”‚       β”‚       β”œβ”€β”€ product_authorizer_test.exs
β”‚   β”‚       β”‚       β”œβ”€β”€ product_repository_test.exs
β”‚   β”‚       β”‚       └── product_test.exs
β”‚   β”‚       β”œβ”€β”€ support
β”‚   β”‚       β”‚   β”œβ”€β”€ entity_case.ex
β”‚   β”‚       β”‚   β”œβ”€β”€ factories
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ account
β”‚   β”‚       β”‚   β”‚   β”‚   β”œβ”€β”€ organization_factory.ex
β”‚   β”‚       β”‚   β”‚   β”‚   β”œβ”€β”€ sales_channel_factory.ex
β”‚   β”‚       β”‚   β”‚   β”‚   β”œβ”€β”€ shop_factory.ex
β”‚   β”‚       β”‚   β”‚   β”‚   └── user_factory.ex
β”‚   β”‚       β”‚   β”‚   └── inventory
β”‚   β”‚       β”‚   β”‚       β”œβ”€β”€ collection_factory.ex
β”‚   β”‚       β”‚   β”‚       └── product_factory.ex
β”‚   β”‚       β”‚   β”œβ”€β”€ factory.ex
β”‚   β”‚       β”‚   └── repository_case.ex
β”‚   β”‚       └── test_helper.exs
β”‚   └── web
β”‚   └── api
β”œβ”€β”€ config
β”‚   └── config.exs
β”œβ”€β”€ mix.exs
β”œβ”€β”€ mix.lock
└── pbcopy
4 Likes

@hlx it’s been some time since your response. how are you liking this design choice? @peerreynders do you have any code on github we could take a closer look at? (first impression is: looks amazing)