Umbrella Projects
Managing multiple interconnected applications? This guide teaches the progression from single Mix applications through their organizational limitations to umbrella projects, showing when multi-app monorepos provide production value.
Why It Matters
Most Elixir projects start as single Mix applications. As systems grow, you encounter architectural challenges:
- Donation platform - Core domain, web interface, background workers, admin panel
- E-commerce - Catalog service, payment processing, inventory management, analytics
- Financial system - Contract management, payment gateway, reporting, compliance
- Content platform - API server, content delivery, search indexing, user management
Production question: Should you split into multiple applications, and if so, should they be separate repositories or umbrella apps? The answer depends on your coupling and deployment requirements.
Standard Mix Application
Every Elixir project starts with mix new.
Single Application Structure
mix new donation_platform
# => Creates standard Mix project
# => Structure: Single application
# => Compilation: One compile step
# => Deployment: Single releaseProject structure:
donation_platform/
├── mix.exs # => Project configuration
├── lib/
│ ├── donation_platform.ex # => Main module
│ └── donation_platform/
│ ├── donor.ex # => Domain logic
│ ├── donation.ex # => Domain logic
│ └── campaign.ex # => Domain logic
└── test/
└── donation_platform_test.exsBasic Organization - Folders Only
# Standard single-app organization
donation_platform/
├── lib/
│ └── donation_platform/
│ ├── core/ # => Domain logic folder
│ │ ├── donor.ex
│ │ ├── donation.ex
│ │ └── campaign.ex
│ ├── web/ # => Web interface folder
│ │ ├── router.ex
│ │ └── controllers/
│ └── workers/ # => Background jobs folder
│ ├── email_worker.ex
│ └── report_worker.ex
└── mix.exs
# => All code in single application
# => Compilation: Everything together
# => Testing: All tests run togetherThis works initially but has production limitations.
Limitations of Single Application
As projects grow, single applications create organizational problems.
Problem 1: No Architectural Boundaries
# Web controller directly accessing worker internals
defmodule DonationPlatform.Web.DonorController do
alias DonationPlatform.Workers.EmailWorker
def create(conn, params) do
donor = create_donor(params)
# => Direct dependency on worker implementation
EmailWorker.send_welcome(donor.email, donor.name)
# => Tight coupling between layers
# => No boundary enforcement
# => Type: :ok | {:error, reason}
json(conn, donor)
end
end
# => Web depends on workers
# => Workers depend on core
# => All boundaries voluntary
# => Easy to violate architectureNo compiler enforcement of architectural layers.
Problem 2: Tight Coupling
# Core domain mixed with infrastructure
defmodule DonationPlatform.Core.Donation do
# => Domain logic
def process_donation(donor_id, amount) do
# ... business logic ...
# => Infrastructure concern in domain
send_receipt_email(donor_id, amount) # => Email logic
store_in_cache(donor_id, amount) # => Cache logic
log_to_analytics(donor_id, amount) # => Analytics logic
# => Domain polluted with infrastructure
# => Hard to test domain in isolation
# => Type: {:ok, donation} | {:error, reason}
end
end
# => Everything depends on everything
# => Circular dependencies possible
# => Hard to extract or testAll code shares single namespace and dependency graph.
Problem 3: All-or-Nothing Deployment
# mix.exs - Single application
defp deps do
[
{:phoenix, "~> 1.7"}, # => Web framework
{:ecto_sql, "~> 3.10"}, # => Database
{:oban, "~> 2.15"}, # => Job queue
{:ex_aws, "~> 2.4"}, # => Cloud services
{:broadway, "~> 1.0"} # => Data pipeline
# => All dependencies loaded always
# => Web server loads job queue
# => Workers load Phoenix
# => Type: list(dependency)
]
end
# => Single release includes everything
# => Cannot deploy web separately from workers
# => Scaling requires entire applicationNo way to deploy or scale components independently.
Problem 4: Namespace Collisions
# Everything under one namespace
defmodule DonationPlatform.User do # => User for web auth?
# ...
end
defmodule DonationPlatform.User do # => User for donations?
# => Compilation error: Already defined
# => Type: Compilation error
end
# Must use verbose names
defmodule DonationPlatform.Web.User do # => Web auth user
# ...
end
defmodule DonationPlatform.Core.Donor do # => Donation user (renamed)
# ...
end
# => Naming confusion
# => Verbose module names
# => Context conflictsSingle namespace forces naming conventions to avoid conflicts.
Problem 5: Long Compilation Times
# Any change recompiles entire application
mix compile
# => Compiles: Core, Web, Workers, Admin
# => Time: 30-60 seconds for large projects
# => Type: Compilation result
# Changed one file in workers
touch lib/donation_platform/workers/email_worker.ex
mix compile
# => Recompiles: All dependencies of workers
# => Potentially: Web, Core if dependencies exist
# => No isolation benefitNo way to compile subsystems independently.
Problem 6: Testing Complexity
# All tests run together
mix test
# => Runs: Core tests (unit)
# => Runs: Web tests (integration)
# => Runs: Worker tests (async jobs)
# => Time: 5-10 minutes
# => Type: Test results
# Want to test only core domain?
mix test test/donation_platform/core
# => Still loads: All dependencies
# => Still starts: Database, cache, etc.
# => No isolationCannot test subsystems in isolation without loading entire application.
Umbrella Projects - Multi-App Monorepo
Umbrella projects provide architectural boundaries within single repository.
Creating Umbrella Project
mix new donation_platform --umbrella
# => Creates umbrella project structure
# => Type: Umbrella project
# => Structure: apps/ directory for applicationsGenerated structure:
donation_platform/
├── mix.exs # => Root configuration
├── apps/ # => Applications directory
│ └── .gitkeep
└── config/
└── config.exsAdding Applications
cd donation_platform/apps
mix new core
# => Creates: apps/core/
# => Type: Standard Mix application
# => Purpose: Domain logic
mix new web --sup
# => Creates: apps/web/
# => Type: Supervised application
# => Purpose: Phoenix web interface
mix new workers --sup
# => Creates: apps/workers/
# => Type: Supervised application
# => Purpose: Oban job processing
mix new admin --sup
# => Creates: apps/admin/
# => Type: Supervised application
# => Purpose: Admin interfaceFinal structure:
donation_platform/
├── mix.exs # => Root umbrella config
├── apps/
│ ├── core/ # => Domain logic app
│ │ ├── mix.exs
│ │ └── lib/
│ │ └── core/
│ │ ├── donor.ex
│ │ ├── donation.ex
│ │ └── campaign.ex
│ ├── web/ # => Web interface app
│ │ ├── mix.exs
│ │ └── lib/
│ │ └── web/
│ │ ├── router.ex
│ │ └── controllers/
│ ├── workers/ # => Background jobs app
│ │ ├── mix.exs
│ │ └── lib/
│ │ └── workers/
│ │ ├── email_worker.ex
│ │ └── report_worker.ex
│ └── admin/ # => Admin panel app
│ ├── mix.exs
│ └── lib/
│ └── admin/
│ └── dashboard.ex
└── config/
└── config.exsEach application is independent Mix project within umbrella.
Application Dependencies
Umbrella apps declare dependencies on sibling apps.
Defining Dependencies in mix.exs
# apps/web/mix.exs
defmodule Web.MixProject do
use Mix.Project
def project do
[
app: :web,
version: "0.1.0",
build_path: "../../_build", # => Shared build directory
config_path: "../../config/config.exs",
deps_path: "../../deps", # => Shared dependencies
deps: deps() # => Application dependencies
]
end
defp deps do
[
{:core, in_umbrella: true}, # => Depends on core app
# => Type: Internal dependency
# => Compilation: core before web
{:phoenix, "~> 1.7"}, # => External dependencies
{:plug_cowboy, "~> 2.6"}
]
end
end# apps/workers/mix.exs
defmodule Workers.MixProject do
use Mix.Project
def project do
[
app: :workers,
version: "0.1.0",
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
deps: deps()
]
end
defp deps do
[
{:core, in_umbrella: true}, # => Depends on core app
{:oban, "~> 2.15"}, # => Job queue
{:swoosh, "~> 1.11"} # => Email library
# => Does NOT depend on :web
# => Isolated from web concerns
]
end
end# apps/core/mix.exs - No internal dependencies
defmodule Core.MixProject do
use Mix.Project
def project do
[
app: :core,
version: "0.1.0",
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
deps: deps()
]
end
defp deps do
[
{:ecto_sql, "~> 3.10"}, # => External only
{:decimal, "~> 2.0"}
# => No umbrella dependencies
# => Pure domain logic
# => Type: list(dependency)
]
end
endDependency graph enforces architectural layers:
core (no internal deps)
↑
├── web (depends on core)
├── workers (depends on core)
└── admin (depends on core)Compilation Order
mix compile
# => Compiles: core first (no deps)
# => Compiles: web, workers, admin in parallel (depend on core)
# => Type: Compilation result
# => Order: Automatic based on dependenciesMix automatically orders compilation based on dependency graph.
Application Communication
Apps communicate through clean boundaries.
Example - Web Calling Core
# apps/web/lib/web/controllers/donation_controller.ex
defmodule Web.DonationController do
use Web, :controller
# => Import from core app
alias Core.Donations # => Domain service
alias Core.Donor # => Domain struct
# => Type: Module aliases
def create(conn, params) do
# => Call core domain logic
case Donations.process_donation(params) do
{:ok, donation} -> # => Success case
# => Type: {:ok, %Donation{}}
json(conn, donation)
{:error, changeset} -> # => Validation error
# => Type: {:error, Ecto.Changeset.t()}
json(conn, %{errors: format_errors(changeset)})
end
# => Web layer never accesses database directly
# => Core layer handles all business logic
# => Clean separation of concerns
end
endWeb depends on core, but core knows nothing about web.
Example - Workers Calling Core
# apps/workers/lib/workers/receipt_worker.ex
defmodule Workers.ReceiptWorker do
use Oban.Worker
# => Import from core app
alias Core.Donations # => Domain service
alias Core.Donors # => Domain service
@impl Oban.Worker
def perform(%Oban.Job{args: %{"donation_id" => id}}) do
# => Load donation from core
donation = Donations.get_donation!(id)
# => Type: %Core.Donation{}
# => Load donor from core
donor = Donors.get_donor!(donation.donor_id)
# => Type: %Core.Donor{}
# => Send receipt email
send_receipt_email(donor.email, donation)
# => Type: :ok | {:error, reason}
:ok
end
endWorkers depend on core for domain operations.
Shared Dependencies
# Root mix.exs - Shared across all apps
defmodule DonationPlatform.MixProject do
use Mix.Project
def project do
[
apps_path: "apps", # => Applications directory
version: "0.1.0", # => Umbrella version
start_permanent: Mix.env() == :prod,
deps: deps() # => Shared dependencies
]
end
defp deps do
[
# => Shared test/dev dependencies
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.3", only: [:dev], runtime: false}
# => Available to all apps
# => Type: list(dependency)
]
end
endRoot mix.exs defines shared dependencies available to all apps.
Production Patterns
Pattern 1 - Shared Configuration
# config/config.exs - Shared configuration
import Config
# => Configure all apps
config :core, Core.Repo,
database: "donation_platform_#{config_env()}",
pool_size: 10
# => Type: Repo configuration
config :web, Web.Endpoint,
url: [host: "localhost"],
secret_key_base: System.get_env("SECRET_KEY_BASE")
# => Type: Endpoint configuration
config :workers, Oban,
repo: Core.Repo, # => Shared repo
queues: [default: 10, mailers: 20]
# => Type: Oban configuration
# => Environment-specific config
import_config "#{config_env()}.exs"Configuration shared across all umbrella apps.
Pattern 2 - Independent Testing
# Test only core domain
cd apps/core
mix test
# => Runs: Core tests only
# => Loads: Core dependencies only
# => Time: 30 seconds (not 5 minutes)
# => Type: Test results
# Test only web interface
cd apps/web
mix test
# => Runs: Web tests only
# => Loads: Core + Web dependencies
# => Isolated from workers/adminEach app tests independently with only required dependencies.
Pattern 3 - Selective Releases
# rel/web_release.exs - Web-only release
import Config
# => Include only web and core apps
config :web_release,
applications: [:core, :web] # => Exclude: workers, admin
# => Type: Release configuration
# Deployment:
# - Web servers: Release with :core + :web
# - Worker servers: Release with :core + :workers
# - Admin servers: Release with :core + :adminDifferent releases for different deployment targets.
Pattern 4 - Clean Architectural Layers
Core (Domain)
- No external app dependencies
- Pure business logic
- Ecto schemas and changesets
- Domain services
Web (Interface)
- Depends on: Core
- Phoenix controllers/views
- GraphQL/REST APIs
- WebSocket channels
Workers (Background)
- Depends on: Core
- Oban jobs
- Scheduled tasks
- Email delivery
Admin (Management)
- Depends on: Core
- Admin dashboard
- Management tools
- ReportingClear separation prevents architectural violations.
When to Use Umbrella Projects
Use Umbrella When
1. Multiple Deployment Targets
# Different services need different apps
# - Web servers: core + web
# - API servers: core + api
# - Workers: core + workers
# - Admin: core + admin2. Architectural Boundaries
# Want to enforce clean architecture
# - Core: Domain logic (no external knowledge)
# - Interface: Web/API (depends on core)
# - Infrastructure: Workers/Services (depends on core)3. Team Organization
# Different teams own different apps
# - Core team: Domain logic
# - Web team: User interfaces
# - Platform team: Background services4. Compilation Performance
# Large codebase benefits from isolation
# - Change in workers: No need to recompile web
# - Change in web: No need to recompile workers
# - Core changes: Recompile dependents onlyKeep Single App When
1. Simple Projects
# Small projects (< 10,000 LOC)
# Single deployment target
# No architectural complexity2. Tight Integration
# All components tightly coupled
# Share most dependencies
# Deploy together always3. Early Stage
# Product direction unclear
# Requirements changing rapidly
# Premature optimization riskMigration Path
From Single App to Umbrella
Step 1: Create Umbrella Structure
# Outside existing project
mix new donation_platform_umbrella --umbrella
cd donation_platform_umbrella/apps
# Move existing app
mv ../../donation_platform ./legacyStep 2: Extract Core Domain
cd apps
mix new core
# Move domain logic
mv legacy/lib/donation_platform/donor.ex core/lib/core/
mv legacy/lib/donation_platform/donation.ex core/lib/core/
mv legacy/lib/donation_platform/campaign.ex core/lib/core/Step 3: Create Specialized Apps
mix new web --sup
mix new workers --sup
# Configure dependencies
# apps/web/mix.exs: {:core, in_umbrella: true}
# apps/workers/mix.exs: {:core, in_umbrella: true}Step 4: Migrate Code
# Move web code to web app
mv legacy/lib/donation_platform/web/* apps/web/lib/web/
# Move worker code to workers app
mv legacy/lib/donation_platform/workers/* apps/workers/lib/workers/Step 5: Update Imports
# Before (single app)
alias DonationPlatform.Core.Donor
# After (umbrella)
alias Core.Donor # => From core appStep 6: Test and Deploy
cd donation_platform_umbrella
mix test # => All apps
mix release # => Umbrella releaseBest Practices
1. Core App Has No Internal Dependencies
# Good: Core isolated
defp deps do
[
{:ecto_sql, "~> 3.10"} # => External only
]
end
# Bad: Core depends on other apps
defp deps do
[
{:web, in_umbrella: true} # => Circular dependency risk
]
end2. Apps Depend on Core, Not Each Other
# Good: Star topology
# core ← web
# core ← workers
# core ← admin
# Bad: Circular dependencies
# web ← workers ← admin ← web3. Shared Code Goes in Core
# Good: Shared in core
defmodule Core.Donations do
# => Used by: web, workers, admin
end
# Bad: Duplicated across apps
defmodule Web.Donations do ... end
defmodule Workers.Donations do ... end4. Use Path Dependencies for Development
# apps/web/mix.exs
defp deps do
[
{:core, in_umbrella: true}, # => Development: Path dependency
# => Production: Git tag or Hex
]
end5. Test Apps Independently
# Test each app in isolation
cd apps/core && mix test
cd apps/web && mix test
cd apps/workers && mix testCommon Pitfalls
Pitfall 1: Over-Splitting Too Early
# Wrong: Too many apps for small project
apps/
├── core/
├── web/
├── api/
├── workers/
├── admin/
├── reporting/
└── analytics/
# => 7 apps for 5,000 LOC project
# => Premature complexityPitfall 2: Circular Dependencies
# Wrong: Circular deps
# apps/web/mix.exs
{:workers, in_umbrella: true}
# apps/workers/mix.exs
{:web, in_umbrella: true} # => Compilation errorPitfall 3: Duplicating Code Instead of Sharing
# Wrong: Duplicate validation logic
# apps/web/lib/web/donation_validator.ex
def validate(donation), do: ...
# apps/workers/lib/workers/donation_validator.ex
def validate(donation), do: ... # => Duplicate
# Right: Share in core
# apps/core/lib/core/donations.ex
def validate(donation), do: ... # => Single sourcePitfall 4: Not Using Umbrella for Multiple Services
# Wrong: Single app for web + workers + admin
# Even with folders, no compilation isolation
# Right: Umbrella with separate deployments
# - Web servers: core + web
# - Worker servers: core + workersFurther Reading
Architecture patterns:
- Application Structure - Application behavior and supervision
- Supervisor Trees - Multi-app supervision strategies
Configuration:
- Configuration Management - Environment-specific config for umbrellas
Deployment:
- Deployment Strategies - Releasing umbrella applications
Summary
Umbrella projects provide multi-app organization within single repository:
- Standard Mix Application - Single app, folders for organization
- Limitations - No boundaries, tight coupling, all-or-nothing deployment
- Umbrella Structure - Multi-app with explicit dependencies
- Production Benefits - Clean architecture, selective releases, compilation isolation
Use single app for simple projects, tight integration, early stage development.
Use umbrella for architectural boundaries, multiple deployment targets, large codebases.
Both approaches serve different needs - choose based on project scale and deployment requirements.