Skip to content

Instantly share code, notes, and snippets.

@dhinojosa
Created January 29, 2026 17:39
Show Gist options
  • Select an option

  • Save dhinojosa/27f006afc46d0d8ea5bd29ab14d232b1 to your computer and use it in GitHub Desktop.

Select an option

Save dhinojosa/27f006afc46d0d8ea5bd29ab14d232b1 to your computer and use it in GitHub Desktop.
Agent for Play, Slick, TypeLevelCats with Ports and Adapters Architecture

AGENTS

This repository is a Scala Play Framework application built with a strong functional core and rigorous testing practices.


What This Is

  • Play Framework application written in Scala
  • Functional programming using Typelevel Cats and type classes
  • Hexagonal (Ports and Adapters) architecture
  • Strong emphasis on types as architectural documentation
  • Test suite using Play testing utilities, ScalaCheck, and TestContainers

Goals

  • Clear separation of domain logic and infrastructure
  • Typeclass-driven abstractions for composability and testability
  • Explicit modeling of success, failure, and observability
  • Reliable integration and property-based testing
  • Feature-based modularization under app/features
  • Prefer monadic composition and explicit effects
  • Two primary effects are used:
    • DBIO (from Slick) for database interaction
    • scala.concurrent.Future for boundary / controller execution

Feature Structure

Each feature is encapsulated in its own module under:

app/features/<feature-name>/

Features are designed to be independent and reusable.

Standard Feature Layout

The following directories are used in each feature, app/features/<feature-name> to delineate ports and adapter boundaries

  • domain
    • Business logic only
    • No references to infrastructure or external systems
  • domain/model
    • Domain entities and value objects
  • domain/service
    • Domain services coordinating domain logic
  • domain/events
    • Domain events
  • application
    • Application services and coordination logic
  • application/command
    • Command ADTs
  • application/command/error
    • Command error ADTs
  • application/command/result
    • Command result ADTs (past-tense acknowledgements)
  • application/query
    • Query input models (may be empty)
  • application/query/error
    • Query error ADTs
  • application/query/result
    • Query result DTOs (read models, views)
  • application/service
    • Application services implementing port/in
  • port
    • Hexagonal Architecture Ports
  • port/in
    • Ports for application services to extend and for controllers to inject and use
  • port/out
    • Ports for infrastructure adapters to implement and for application services to inject and use
  • infrastructure
    • Persistence and infrastructure implementations
  • infrastructure/adapter/in
    • Infrastructure implementations like CLI, UI, AI to drive the application. Injects port/in
  • infrastructure/adapter/out
    • Infrastructure implementations like databases, messaging, external APIs.
  • provider
    • Objects used to make dependency injection easier with Guice in Play

Query Result DTO Naming Conventions

Query result DTOs are read models, not domain objects. They are shaped for consumption and presentation.

Common naming conventions:

  • <Aggregate>Summary – lightweight summary
  • <Aggregate>Card – lightweight presentation DTO
  • <Aggregate>Details – medium-weight DTO
  • <Aggregate>View – presentation-oriented view DTO
  • <Aggregate>Page – paginated collection DTO
  • <Aggregate>Profile – large, enriched representation
  • <Aggregate>Document – full document-style representation
  • <Aggregate>Graph – graph or network representation
  • <Aggregate>Snapshot – temporal snapshot
  • <Aggregate>History – temporal history

Metadata and Observability

case class Metadata(
  correlationId: UUID,
  timestamp: Instant,
  warnings: Chain[QueryWarning]
)

case class WithMeta[M, A](
  metadata: M,
  value: A
)
  • Metadata is orthogonal to success and failure
  • Metadata can be present whenever a value is returned, but it is not mandatory
  • Warnings are informational and never control flow

Query Port Conventions (Scala)

Core Principle
If a guaranteed query returns, it has results.
Errors abort execution.
Warnings annotate results.
Metadata is orthogonal.
Lookups may return None without being an error.


Fundamental Rules

Hard Errors

  • Hard errors are fatal
  • They abort execution
  • They live in the effect failure channel
MonadError[F, Throwable].raiseError(QueryError)

Hard errors are not returned as values.


Hard Error Modeling

  • Hard Errors are modeled as ADTs
  • They are located in application/query/error for queries, and application/command/error for commands
  • The trait for the errors extends Throwable so they can be raised in the effect and used in the error channel
sealed trait PublicAccountCommandError extends Throwable
object PublicAccountCommandError {
  case class NotFound(id: UUID) extends PublicAccountCommandError
  case class AuthenticationFailed(email: String) extends PublicAccountCommandError
}

Warnings

  • Warnings are non-fatal
  • They never control the flow
  • They annotate successful results
  • Warnings are carried in metadata

Lookup by Identifier (0–1)

findById(id): F[WithMeta[Metadata, Option[A]]]
  • Absence is not an error
  • At most one result
  • Metadata is available when using WithMeta

Without metadata:

findById(id): F[Option[A]]

Guaranteed Results (Non-Empty)

Single Result (1)

findByCriteria(criteria): F[WithMeta[Metadata, A]]
  • Exactly one result is guaranteed
  • Absence indicates a logic or invariant error

Multiple Results (1+)

findByCriteria(criteria): F[WithMeta[Metadata, OneAnd[List, A]]]
  • At least one result is guaranteed
  • Empty results are illegal

Search / Listing Results (0+)

findByCriteria(criteria): F[List[A]]
  • Empty is allowed and not an error
  • Use metadata only when observability (warnings, correlation, timing, etc.) is needed

Validation

  • Validation is fail-fast
  • Invalid criteria abort execution
  • Validation errors are hard errors raised in the effect

Warnings

Warnings annotate results via metadata:

metadata.warnings

There are:

  • no partial success states
  • warnings only ever accompany success

Summary Matrix

Scenario Absence Errors Warnings Cardinality Metadata
Lookup by ID Yes Effect No 0–1 Optional
Guaranteed single result No Effect No 1 Optional
Guaranteed multiple result No Effect No 1+ Optional
Search / listing Yes Effect No 0+ Optional
Search with warnings No Effect Metadata 1+ Required

Command Port Conventions (Scala)

Commands represent intent to change system state.


Command Principles

  • Commands are application-level ADTs
  • Commands express what the user wants to do
  • Commands are validated before execution
  • Commands either succeed or fail — no partial success
  • Commands never return Option, Either, Validated, or Ior
  • Failures abort execution via the effect

Command ADTs

Defined in:

application/command
  • Commands represent raw intent and an action to be taken.
  • They are not domain objects.
  • They are not domain events.
  • They are not read models.
sealed trait AdminAccountCommand
object AdminAccountCommand {
  final case class Update(account: Account) extends AdminAccountCommand
  final case class Delete(accountId: AccountId) extends AdminAccountCommand
}

Command Results

  • Past-tense acknowledgements
  • Not domain events
  • Defined in application/command/result
  • Naming Convention is role, aggregate(s) name, CommandResult
  • ADT contains the different results to be expected
sealed trait AdminTalkCommandResult
object AdminTalkCommandResult {
  final case class TalkCreated(id: TalkId) extends AdminTalkCommandResult
  final case class TalkUpdated(id: TalkId) extends AdminTalkCommandResult
  final case object TalkDeleted extends AdminTalkCommandResult
}

Command Errors

  • Represent invariant or authorization violations
  • Raised via the effect
  • Never returned as values

Command Ports

Defined in:

port/in

Example:

trait AdminArticleCommandPort[F[_]] {
  def execute(
    command: AdminArticleCommand
  ): F[AdminArticleCommandResult]
}

Command Execution Semantics

  • Validate before persistence
  • Abort on failure
  • No partial success
  • Atomic execution
  • Results acknowledge completion

Ports

Input Ports (port/in)

  • Define use-case boundaries
  • Implemented by application services
  • Injected into controllers
  • Parameterized by effect type F[_]
  • Reference application command/query ADTs

Output Ports (port/out)

  • Represent external dependencies
  • Abstract persistence, messaging, and integrations
  • Use domain aggregates
  • Implemented by infrastructure adapters

Output Port Naming Conventions

Responsibility Port Interface Adapter Implementation
Persistence / DB OrderRepository SlickOrderRepository, JpaOrderRepository
Messaging / Events OrderEventPublisher KafkaOrderEventPublisher
External API ProductCatalogClient RestProductCatalogClient
Email / Notification NotificationSender SmtpNotificationSender
File Storage FileStorageService S3FileStorageService
LLM / AI Service LLMClient OpenAIClient
Payment Gateway PaymentProcessor StripePaymentProcessor
Search SearchService ElasticSearchGateway

Services

  • Located in application/service
  • Implement port/in
  • Inject port/out
  • Translate effects using FunctionK[F, G]
  • Enforce validation and invariants

Repositories

  • Implement output ports port/out
  • Use domain aggregates
    • as parameters, typically the id value objects
    • as return objects
  • Return effectful values (F[A])
  • Rely on the effect’s error channel
  • In this project it is done with Slick
  • Named with the database technology Slick then the aggregate it represents Article then Repository
  • Uses dbConfigProvider, extends HasDatabaseConfigProvider[JdbcProfile], also extends the port ArticleRepository[slick.dbio.DBIO]
class SlickArticleRepository @Inject() (protected val dbConfigProvider: DatabaseConfigProvider)(
  implicit ec: ExecutionContext
) extends HasDatabaseConfigProvider[JdbcProfile]
    with ArticleRepository[slick.dbio.DBIO] {
  • Use the term <Aggregate>Table to represent the database table that an aggregate has a relationship with
final class ArticleTable(tag: Tag) extends Table[Article](tag, "article") {

    def id = column[UUID]("id", O.PrimaryKey)

    def title = column[String]("title")

    def content = column[String]("content")

    def oneLineSummary = column[String]("oneLineSummary")

    def summary = column[String]("summary")

    def publishedDate = column[Option[Long]]("publishedDate")

    def updatedDate = column[Option[Long]]("updatedDate")

    def slug = column[String]("slug")

    def * = (id, title, oneLineSummary, summary, content, publishedDate, updatedDate, slug) <> (Article.tupled, Article.unapply)
}
  • Uses case classes to represent the rows, these may look similar to domain models, but they are not, they are used by the repository exclusively. They also will typically contain raw values as opposed to aggregates
case class Article(id: UUID,
                   title: String,
                   oneLineSummary: String,
                   summary: String,
                   content: String,
                   publishedDate: Option[Long],
                   updatedDate: Option[Long],
                   slug: String
)

Evolutions

  • Managed by Slick
  • Located in conf/evolutions
  • Versioned and applied sequentially
  • Changes in the Repository should always reflect a change in the evolution

Database

  • Managed with Slick
  • Configured in conf/application.conf
  • Current database: MySQL

Testing

  • ScalaCheck for property-based testing
  • Play testing utilities
  • TestContainers for integration testing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment