Files
2026-02-04 02:18:31 +01:00

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:

  1. Parses YAML frontmatter for page config
  2. Translates Twig-like syntax to ScalaTags calls
  3. 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

  1. Type Safety: SummerCMS gains compile-time query validation (Quill) and typed errors (ZIO) that WinterCMS lacks.

  2. Concurrency: ZIO fibers enable true async/concurrent plugin loading and request handling vs PHP's request-per-process model.

  3. Resource Safety: ZIO's Scope ensures database connections, file handles, and plugin resources are properly released even on errors.

  4. Model Extension: WinterCMS uses runtime extend() calls. SummerCMS uses compile-time type classes for static extensions, plus events for runtime extensions.

  5. Testing: ZIO's layer system enables true dependency injection for testing. Mock any layer by providing a test implementation.


Sources