This repository is a Scala Play Framework application built with a strong functional core and rigorous testing practices.
- 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
- 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 interactionscala.concurrent.Futurefor boundary / controller execution
Each feature is encapsulated in its own module under:
app/features/<feature-name>/
Features are designed to be independent and reusable.
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
- Application services implementing
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 implementations like CLI, UI, AI to drive the application. Injects
infrastructure/adapter/out- Infrastructure implementations like databases, messaging, external APIs.
provider- Objects used to make dependency injection easier with Guice in Play
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
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
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.
- 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 Errors are modeled as ADTs
- They are located in
application/query/errorfor queries, andapplication/command/errorfor commands - The
traitfor the errors extendsThrowableso 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 are non-fatal
- They never control the flow
- They annotate successful results
- Warnings are carried in metadata
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]]findByCriteria(criteria): F[WithMeta[Metadata, A]]- Exactly one result is guaranteed
- Absence indicates a logic or invariant error
findByCriteria(criteria): F[WithMeta[Metadata, OneAnd[List, A]]]- At least one result is guaranteed
- Empty results are illegal
findByCriteria(criteria): F[List[A]]- Empty is allowed and not an error
- Use metadata only when observability (warnings, correlation, timing, etc.) is needed
- Validation is fail-fast
- Invalid criteria abort execution
- Validation errors are hard errors raised in the effect
Warnings annotate results via metadata:
metadata.warningsThere are:
- no partial success states
- warnings only ever accompany success
| 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 |
Commands represent intent to change system state.
- 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, orIor - Failures abort execution via the effect
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
}- 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
}- Represent invariant or authorization violations
- Raised via the effect
- Never returned as values
Defined in:
port/in
Example:
trait AdminArticleCommandPort[F[_]] {
def execute(
command: AdminArticleCommand
): F[AdminArticleCommandResult]
}- Validate before persistence
- Abort on failure
- No partial success
- Atomic execution
- Results acknowledge completion
- Define use-case boundaries
- Implemented by application services
- Injected into controllers
- Parameterized by effect type
F[_] - Reference application command/query ADTs
- Represent external dependencies
- Abstract persistence, messaging, and integrations
- Use domain aggregates
- Implemented by infrastructure adapters
| 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 |
- Located in
application/service - Implement
port/in - Inject
port/out - Translate effects using
FunctionK[F, G] - Enforce validation and invariants
- 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
Slickthen the aggregate it representsArticlethenRepository - Uses
dbConfigProvider, extendsHasDatabaseConfigProvider[JdbcProfile], also extends the portArticleRepository[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>Tableto 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
)- Managed by Slick
- Located in
conf/evolutions - Versioned and applied sequentially
- Changes in the Repository should always reflect a change in the evolution
- Managed with Slick
- Configured in
conf/application.conf - Current database: MySQL
- ScalaCheck for property-based testing
- Play testing utilities
- TestContainers for integration testing