27 KiB
Architecture Research: SummerCMS
Domain: Scala/ZIO-based Content Management Framework (CMF) Researched: 2026-02-04 Overall Confidence: MEDIUM-HIGH
This document defines the recommended architecture for SummerCMS, a Scala rewrite of WinterCMS using the ZIO ecosystem. The architecture leverages ZIO's effect system and layer-based dependency injection to create a modular, extensible CMF.
High-Level Architecture
+------------------+
| HTMX/Vue |
| Frontend |
+--------+---------+
|
v
+-----------------------------------------------------------------------------------+
| HTTP LAYER (ZIO HTTP) |
| +-------------+ +-------------+ +-------------+ +------------------+ |
| | Public | | Admin | | API | | Static Assets | |
| | Routes | | Routes | | Routes | | Handler | |
| +------+------+ +------+------+ +------+------+ +------------------+ |
| | | | |
| +----------------+----------------+ |
| | |
| v |
| +--------------------------------------------------------------------+ |
| | MIDDLEWARE STACK | |
| | Auth | CORS | Logging | Metrics | Error | Rate Limit | Plugin | |
| +--------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| REQUEST PIPELINE |
| +-------------+ +-------------+ +-------------+ +------------------+ |
| | Route | | Theme | | Component | | Response | |
| | Resolver | | Resolver | | Resolver | | Builder | |
| +------+------+ +------+------+ +------+------+ +------------------+ |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| CORE SERVICES LAYER |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
| | Plugin | | Theme | | Config | | Event | | Cache | |
| | Registry | | Engine | | Service | | Bus | | Service | |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
| |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
| | Template | | Form | | Auth | | Media | | Queue | |
| | Engine | | Builder | | Service | | Manager | | Service | |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| DOMAIN LAYER (Pure Business Logic) |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
| | Models | | Commands | | Queries | | Validators| | Transforms| |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+ |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| DATA ACCESS LAYER |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| | Repository| | Query | | Migration | | Schema | |
| | Interfaces| | Builder | | System | | Registry | |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| | |
| v |
| +--------------------------------------------------------------------+ |
| | ZIO Quill (PostgreSQL) | |
| +--------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------------+
Core Components
1. Request Pipeline
Responsibility: Routes incoming HTTP requests through theme/component resolution to response generation.
// Conceptual flow
Request
-> RouteResolver // Determines page/route from URL
-> ThemeResolver // Loads theme and layout
-> ComponentResolver // Instantiates page components
-> PageRenderer // Renders template with component data
-> Response
Key Types:
trait RouteResolver {
def resolve(path: Path, method: Method): ZIO[Any, RouteError, ResolvedRoute]
}
sealed trait ResolvedRoute
case class PageRoute(page: Page, params: Map[String, String]) extends ResolvedRoute
case class ApiRoute(handler: Handler, params: Map[String, String]) extends ResolvedRoute
case class StaticRoute(asset: Asset) extends ResolvedRoute
case class RedirectRoute(target: String, permanent: Boolean) extends ResolvedRoute
Build Order: 2nd (after Plugin System foundation)
2. Plugin System
Responsibility: Plugin discovery, lifecycle management, dependency resolution, and extension point registry.
This is the foundational component. Everything else builds on top of it.
// Plugin definition (similar to WinterCMS Plugin.php)
trait Plugin {
def pluginDetails: PluginDetails
def require: List[PluginId] = Nil // Dependencies
// Lifecycle hooks
def register: ZIO[PluginEnvironment, Nothing, Unit]
def boot: ZIO[PluginEnvironment, PluginError, Unit]
// Registration methods
def registerComponents: Map[String, ComponentFactory] = Map.empty
def registerNavigation: List[NavigationItem] = List.empty
def registerSettings: List[SettingsPage] = List.empty
def registerPermissions: List[Permission] = List.empty
def registerMailTemplates: List[MailTemplate] = List.empty
def registerSchedule: List[ScheduledTask] = List.empty
}
// Plugin registry service
trait PluginRegistry {
def discover: ZIO[Any, PluginError, List[PluginManifest]]
def load(id: PluginId): ZIO[Any, PluginError, Plugin]
def enable(id: PluginId): ZIO[Any, PluginError, Unit]
def disable(id: PluginId): ZIO[Any, PluginError, Unit]
def getEnabled: ZIO[Any, Nothing, List[Plugin]]
def resolveDependencies(id: PluginId): ZIO[Any, PluginError, List[PluginId]]
}
Plugin Extension Mechanism (replicating WinterCMS patterns):
// 1. Event-based extension (primary mechanism)
trait EventBus {
def emit[E](event: E): ZIO[Any, Nothing, Unit]
def subscribe[E: Tag](handler: E => ZIO[Any, Nothing, Unit]): ZIO[Any, Nothing, Subscription]
}
// Events plugins can emit/subscribe to
case class ModelCreated[M](model: M)
case class ModelUpdated[M](model: M, changes: Map[String, Any])
case class FormExtending(formWidget: FormWidget, model: Any, context: String)
case class ListExtending(listWidget: ListWidget, model: Any)
// 2. Model extension via type classes
trait ModelExtensions[M] {
def relations: Map[String, Relation[_, _]] = Map.empty
def attributes: Map[String, Attribute[_]] = Map.empty
def behaviors: List[Behavior[M]] = List.empty
}
// 3. Form field injection
trait FormFieldExtension {
def extendFields(form: FormDefinition, model: Any, context: String): FormDefinition
}
ZIO Layer Structure:
val pluginRegistryLayer: ZLayer[Config & Database, PluginError, PluginRegistry] =
ZLayer.fromFunction(PluginRegistryLive.apply _)
val eventBusLayer: ZLayer[Any, Nothing, EventBus] =
ZLayer.succeed(EventBusLive())
Build Order: 1st (foundation for everything)
3. Theme Engine
Responsibility: Theme loading, template rendering, asset management, layout composition.
trait ThemeEngine {
def getActive: ZIO[Any, ThemeError, Theme]
def setActive(id: ThemeId): ZIO[Any, ThemeError, Unit]
def render(page: Page, data: PageData): ZIO[Any, RenderError, Html]
def renderPartial(name: String, data: Map[String, Any]): ZIO[Any, RenderError, Html]
}
trait Theme {
def id: ThemeId
def pages: ZIO[Any, ThemeError, List[PageDefinition]]
def layouts: ZIO[Any, ThemeError, List[LayoutDefinition]]
def partials: ZIO[Any, ThemeError, List[PartialDefinition]]
def assets: AssetManager
}
// Template definition (parsed from .htm files with YAML frontmatter)
case class PageDefinition(
url: String,
layout: Option[String],
title: Option[String],
description: Option[String],
components: Map[String, ComponentConfig], // Component alias -> config
markup: String
)
Template Rendering Options:
| Option | Recommendation | Rationale |
|---|---|---|
| ScalaTags | Recommended | Type-safe, fast (2x Twirl), Scala-native, HTMX-friendly |
| Twirl | Alternative | Play Framework standard, familiar syntax |
| Custom Parser | For WinterCMS .htm compat | Parse YAML frontmatter + Twig-like syntax |
For maximum WinterCMS compatibility, implement a custom template parser that:
- Parses YAML frontmatter for page config
- Translates Twig-like syntax to ScalaTags calls
- Supports component embedding via
{% component 'alias' %}
Build Order: 4th (needs Plugin System, Config, Components)
4. Component System
Responsibility: Reusable UI components with lifecycle, properties, and AJAX handlers.
// Component definition (mirrors WinterCMS components)
trait Component {
def componentDetails: ComponentDetails
def defineProperties: List[PropertyDefinition] = Nil
// Lifecycle
def init: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
def onRun: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
// Data for template
def data: ZIO[ComponentEnv, ComponentError, Map[String, Any]]
// AJAX/HTMX handlers (replaces WinterCMS onXxx methods)
def handlers: Map[String, Handler] = Map.empty
}
// Handler for HTMX requests
type Handler = Request => ZIO[ComponentEnv, ComponentError, Response]
// Component factory for registration
trait ComponentFactory {
def create(config: ComponentConfig): ZIO[Any, ComponentError, Component]
}
// Property definition (for admin UI)
case class PropertyDefinition(
name: String,
title: String,
description: Option[String],
`type`: PropertyType, // string, dropdown, checkbox, etc.
default: Option[Any],
options: Option[Map[String, String]], // For dropdowns
required: Boolean = false,
validation: List[Validator] = Nil
)
HTMX Integration:
// Component can define HTMX handlers
trait Component {
// Traditional handler approach
def onLoadMore(request: Request): ZIO[ComponentEnv, ComponentError, Response] = ???
// These become routes: POST /component/{alias}/onLoadMore
// HTMX attributes: hx-post="/component/posts/onLoadMore" hx-target="#posts-list"
}
Build Order: 3rd (needs Plugin System, Config)
5. Admin Backend
Responsibility: Backend administration interface with YAML-driven forms and lists.
// Backend controller (mirrors WinterCMS Backend\Classes\Controller)
trait BackendController {
def actions: Map[String, Action] = Map.empty
// Standard CRUD actions
def index: Action = listAction
def create: Action = formAction(FormMode.Create)
def update(id: Id): Action = formAction(FormMode.Update, Some(id))
def preview(id: Id): Action = formAction(FormMode.Preview, Some(id))
}
// Form configuration (parsed from fields.yaml)
case class FormConfig(
fields: Map[String, FieldDefinition],
tabs: Option[TabsConfig],
secondaryTabs: Option[TabsConfig]
)
case class FieldDefinition(
label: String,
`type`: FieldType, // text, textarea, dropdown, relation, etc.
span: Span = Span.Auto, // left, right, full, auto
required: Boolean = false,
placeholder: Option[String] = None,
options: Option[FieldOptions] = None,
dependsOn: List[String] = Nil,
trigger: Option[TriggerConfig] = None
)
// List configuration (parsed from columns.yaml)
case class ListConfig(
columns: Map[String, ColumnDefinition],
recordUrl: Option[String],
perPage: Int = 20,
showCheckboxes: Boolean = true,
toolbar: ToolbarConfig
)
YAML Parsing:
// Use zio-json-yaml or yaml4s for parsing
val formConfig: ZIO[Any, ParseError, FormConfig] =
YamlParser.parse[FormConfig](yamlContent)
Build Order: 6th (needs most other components)
6. Database Layer
Responsibility: Type-safe database access, migrations, model definitions.
Recommendation: ZIO Quill because:
- Compile-time query generation (catches SQL errors at compile time)
- Native ZIO integration via
quill-jdbc-zio - Excellent PostgreSQL support
- Minimal runtime overhead
// Model definition (case class + schema)
case class Post(
id: Long,
title: String,
slug: String,
content: Option[String],
publishedAt: Option[Instant],
authorId: Long,
createdAt: Instant,
updatedAt: Instant
)
object Post {
implicit val schema: Schema[Post] = DeriveSchema.gen[Post]
// Quill query definitions
inline def posts = quote { query[Post] }
inline def bySlug(slug: String) = quote { posts.filter(_.slug == lift(slug)) }
inline def published = quote { posts.filter(_.publishedAt.isDefined) }
}
// Repository pattern with ZIO
trait PostRepository {
def findById(id: Long): ZIO[Any, RepositoryError, Option[Post]]
def findBySlug(slug: String): ZIO[Any, RepositoryError, Option[Post]]
def findPublished(page: Int, perPage: Int): ZIO[Any, RepositoryError, Page[Post]]
def create(post: Post): ZIO[Any, RepositoryError, Post]
def update(post: Post): ZIO[Any, RepositoryError, Post]
def delete(id: Long): ZIO[Any, RepositoryError, Unit]
}
// Implementation using Quill
class PostRepositoryLive(quill: Quill.Postgres[SnakeCase]) extends PostRepository {
import quill._
def findById(id: Long): ZIO[Any, RepositoryError, Option[Post]] =
run(quote { Post.posts.filter(_.id == lift(id)) })
.map(_.headOption)
.mapError(RepositoryError.fromThrowable)
}
Migration System:
// Migrations as versioned ZIO effects
trait Migration {
def version: MigrationVersion
def description: String
def up: ZIO[Database, MigrationError, Unit]
def down: ZIO[Database, MigrationError, Unit]
}
// Plugin migrations
trait Plugin {
def migrations: List[Migration] = List.empty
}
Build Order: 1st-parallel (can be built alongside Plugin System foundation)
7. Authentication & Authorization
Responsibility: User authentication, JWT tokens, permissions, access control.
// Auth service
trait AuthService {
def authenticate(credentials: Credentials): ZIO[Any, AuthError, User]
def validateToken(token: JwtToken): ZIO[Any, AuthError, User]
def generateToken(user: User): ZIO[Any, AuthError, JwtToken]
def refreshToken(token: JwtToken): ZIO[Any, AuthError, JwtToken]
def hasPermission(user: User, permission: Permission): ZIO[Any, Nothing, Boolean]
}
// Middleware for protected routes
val authMiddleware: Middleware[AuthService] =
Middleware.interceptIncomingHandler { request =>
for {
token <- extractBearerToken(request)
user <- ZIO.serviceWithZIO[AuthService](_.validateToken(token))
} yield (request, Context(user))
}
// Permission-based access control
val requirePermission: Permission => Middleware[AuthService] = permission =>
Middleware.interceptIncomingHandler { (request, ctx: Context) =>
ZIO.serviceWithZIO[AuthService](_.hasPermission(ctx.user, permission))
.filterOrFail(identity)(AuthError.Forbidden)
.as((request, ctx))
}
Build Order: 5th (needs Database, Plugin System, Config)
8. Configuration Service
Responsibility: Hierarchical configuration from files, database, environment.
trait ConfigService {
def get[A: Schema](key: String): ZIO[Any, ConfigError, A]
def getOpt[A: Schema](key: String): ZIO[Any, ConfigError, Option[A]]
def set[A: Schema](key: String, value: A): ZIO[Any, ConfigError, Unit]
}
// Configuration sources (in priority order)
// 1. Environment variables
// 2. Database (system_settings table)
// 3. Plugin config files (config/*.yaml)
// 4. Theme config files
// 5. Defaults
Build Order: 1st-parallel (early foundation)
Component Boundaries
| Component | Owns | Does NOT Own |
|---|---|---|
| Plugin System | Plugin lifecycle, dependencies, discovery, extension registry | Specific plugin logic |
| Theme Engine | Template loading, rendering, layouts, assets | Page data (components provide this) |
| Component System | Component lifecycle, properties, handlers | Database access (uses Repository) |
| Admin Backend | Form/list rendering, CRUD scaffolding | Business logic (delegates to services) |
| Database Layer | Connections, queries, migrations, schemas | Business rules (pure domain layer) |
| Auth Service | Authentication, tokens, permissions | User registration flows (User plugin) |
| Config Service | Config loading, caching, hierarchy | Config UI (Admin Backend) |
| Event Bus | Event dispatch, subscriptions | Event definitions (plugins define these) |
Data Flow
Frontend Page Request
1. Browser: GET /blog/my-post
|
2. ZIO HTTP: Route matching
|
3. RouteResolver: Lookup page by URL pattern
|-- Database: pages table (or theme file scan)
|-- Returns: PageDefinition { url: "/blog/:slug", components: ["blogPost"] }
|
4. ThemeResolver: Load theme, layout, page template
|-- Filesystem: themes/mytheme/pages/blog-post.htm
|-- Returns: LayoutDefinition, PageTemplate
|
5. ComponentResolver: Instantiate components
|-- PluginRegistry: Get component factories
|-- For each component in page:
| |-- Factory.create(config)
| |-- Component.init()
| |-- Component.onRun()
| |-- Component.data() -> Map[String, Any]
|
6. PageRenderer: Render template with component data
|-- Merge: layout + page + partial templates
|-- Inject: component data into template scope
|-- Execute: ScalaTags/template -> Html
|
7. Response: HTML with HTMX attributes
HTMX Handler Request
1. Browser: POST /component/blogPosts/onLoadMore (HTMX)
|-- Headers: HX-Request: true, HX-Trigger: load-more-btn
|
2. ZIO HTTP: Component handler route
|
3. ComponentResolver:
|-- Load component by alias from session/page context
|-- Component.handlers("onLoadMore")
|
4. Handler execution:
|-- Handler(request) -> ZIO[ComponentEnv, Error, Response]
|-- Returns: HTML fragment
|
5. Response: HTML fragment
|-- HTMX swaps into target element
Admin Form Save
1. Admin: POST /backend/blog/posts/update/5
|-- Body: form data (multipart or JSON)
|
2. BackendController.update(5):
|-- FormWidget.load(fields.yaml)
|-- Validate input against field definitions
|-- Event: FormSaving(widget, model, data) // Plugins can modify
|
3. Model save:
|-- Repository.update(model)
|-- Event: ModelUpdated(model, changes) // Plugins react
|
4. Response: Redirect to list or show success flash
ZIO Patterns
Layer Architecture
// Application layers (bottom-up)
val dataLayer: ZLayer[Any, Nothing, Database & Cache] =
ZLayer.make[Database & Cache](
PostgresDatabase.layer,
RedisCache.layer
)
val coreServicesLayer: ZLayer[Database & Cache, Nothing, Services] =
ZLayer.make[Services](
ConfigServiceLive.layer,
EventBusLive.layer,
AuthServiceLive.layer,
PluginRegistryLive.layer
)
val domainLayer: ZLayer[Services & Database, Nothing, Repositories] =
ZLayer.make[Repositories](
PostRepositoryLive.layer,
UserRepositoryLive.layer,
// ... more repositories
)
val httpLayer: ZLayer[Services & Repositories, Nothing, HttpApp] =
ZLayer.make[HttpApp](
PublicRoutes.layer,
AdminRoutes.layer,
ApiRoutes.layer,
MiddlewareStack.layer
)
// Full application
val appLayer: ZLayer[Any, AppError, HttpApp] =
dataLayer >>> coreServicesLayer >>> domainLayer >>> httpLayer
Effect Organization
// Pure business logic (no effects)
object PostLogic {
def generateSlug(title: String): String =
title.toLowerCase.replaceAll("[^a-z0-9]+", "-")
def validatePost(post: Post): Validated[ValidationError, Post] = ???
}
// Effectful operations wrapped in ZIO
trait PostService {
def createPost(input: CreatePostInput): ZIO[Any, PostError, Post]
}
class PostServiceLive(
repo: PostRepository,
events: EventBus,
auth: AuthService
) extends PostService {
def createPost(input: CreatePostInput): ZIO[Any, PostError, Post] =
for {
_ <- auth.requirePermission(Permission.CreatePost)
slug <- ZIO.succeed(PostLogic.generateSlug(input.title))
valid <- ZIO.fromEither(PostLogic.validatePost(input.toPost(slug)))
created <- repo.create(valid)
_ <- events.emit(PostCreated(created))
} yield created
}
Resource Safety
// Database transactions
def withTransaction[R, E, A](zio: ZIO[R & Transaction, E, A]): ZIO[R & Database, E, A] =
ZIO.scoped {
for {
tx <- Database.beginTransaction
result <- zio.provideSome[R](ZLayer.succeed(tx))
_ <- tx.commit
} yield result
}
// Plugin lifecycle with proper cleanup
def loadPlugins: ZIO[Scope & PluginRegistry, PluginError, List[Plugin]] =
for {
registry <- ZIO.service[PluginRegistry]
plugins <- registry.discover
sorted <- registry.topologicalSort(plugins) // Dependency order
loaded <- ZIO.foreach(sorted) { manifest =>
ZIO.acquireRelease(
registry.load(manifest.id).tap(_.boot)
)(plugin => plugin.shutdown.ignore)
}
} yield loaded
Error Handling
// Domain errors as sealed traits
sealed trait PostError
object PostError {
case class NotFound(id: Long) extends PostError
case class SlugTaken(slug: String) extends PostError
case class ValidationFailed(errors: List[ValidationError]) extends PostError
case class DatabaseError(cause: Throwable) extends PostError
}
// Error recovery and mapping
def findOrCreate(slug: String, default: => Post): ZIO[PostService, Nothing, Post] =
ZIO.serviceWithZIO[PostService](_.findBySlug(slug))
.catchSome { case PostError.NotFound(_) =>
ZIO.serviceWithZIO[PostService](_.create(default))
}
.orDie // Defect if still fails
Build Order
Based on dependencies, build in this order:
Phase 1: Foundation (Parallel)
| Component | Dependencies | Effort |
|---|---|---|
| Database Layer | None | Medium |
| Config Service | None | Low |
| Event Bus | None | Low |
Phase 2: Core Services
| Component | Dependencies | Effort |
|---|---|---|
| Plugin System | Config, Event Bus, Database | High |
Phase 3: Domain Components
| Component | Dependencies | Effort |
|---|---|---|
| Component System | Plugin System, Config | Medium |
| Auth Service | Database, Config | Medium |
Phase 4: Rendering
| Component | Dependencies | Effort |
|---|---|---|
| Theme Engine | Plugin System, Component System, Config | High |
| Template Parser | Theme Engine | Medium |
Phase 5: HTTP Layer
| Component | Dependencies | Effort |
|---|---|---|
| Request Pipeline | All above | Medium |
| Public Routes | Request Pipeline | Low |
| API Routes | Request Pipeline, Auth | Low |
Phase 6: Admin
| Component | Dependencies | Effort |
|---|---|---|
| Admin Backend | All above | High |
| Form Widget System | Admin Backend | High |
| List Widget System | Admin Backend | Medium |
Phase 7: Core Plugins
| Component | Dependencies | Effort |
|---|---|---|
| User Plugin | All core | High |
| Blog Plugin | All core | Medium |
| Pages Plugin | All core | Medium |
WinterCMS Architecture Comparison
| Aspect | WinterCMS | SummerCMS |
|---|---|---|
| Language | PHP (Laravel) | Scala (ZIO) |
| Effect System | None (exceptions) | ZIO effects (typed errors) |
| DI | Laravel Container | ZIO Layers |
| HTTP | Laravel Router | ZIO HTTP |
| Database | Eloquent ORM | ZIO Quill (compile-time) |
| Templates | Twig | ScalaTags + custom parser |
| Events | Laravel Events | ZIO Event Bus |
| Config | PHP arrays/YAML | YAML + ZIO Config |
| Plugin Discovery | Filesystem scan | Filesystem + SPI |
| Model Extension | Dynamic extend() |
Type classes + events |
| Frontend | AJAX (Snowboard) | HTMX + Vue optional |
Key Architectural Differences
-
Type Safety: SummerCMS gains compile-time query validation (Quill) and typed errors (ZIO) that WinterCMS lacks.
-
Concurrency: ZIO fibers enable true async/concurrent plugin loading and request handling vs PHP's request-per-process model.
-
Resource Safety: ZIO's
Scopeensures database connections, file handles, and plugin resources are properly released even on errors. -
Model Extension: WinterCMS uses runtime
extend()calls. SummerCMS uses compile-time type classes for static extensions, plus events for runtime extensions. -
Testing: ZIO's layer system enables true dependency injection for testing. Mock any layer by providing a test implementation.
Sources
- ZIO Architectural Patterns
- ZIO Service Pattern
- ZIO Layers
- Structuring ZIO 2 Applications
- ZIO HTTP Introduction
- ZIO HTTP HTMX Integration
- ZIO Quill Getting Started
- ZIO Schema
- ZIO JSON
- ScalaTags Documentation
- WinterCMS Plugin Extension
- WinterCMS Plugin Registration
- Tapir ZIO HTTP4s Integration
- Plugin Architecture Patterns