[{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/devlog/","section":"Categories","summary":"","title":"Devlog","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/","section":"DHangaard","summary":"","title":"DHangaard","type":"page"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/exam/","section":"Tags","summary":"","title":"Exam","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/reflection/","section":"Tags","summary":"","title":"Reflection","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/series/sheet-herder/","section":"Series","summary":"","title":"Sheet Herder","type":"series"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/sheet-herder/","section":"Tags","summary":"","title":"Sheet Herder","type":"tags"},{"content":" Where We Left Off # The application is live, the pipeline is working, and the semester is drawing to a close. This final devlog is less about what was built and more about how it came together — and what I would do differently.\nWhat Sheet Herder Became # Sheet Herder started as an ambitious idea for a rules-agnostic TTRPG companion app. What it became is a well-structured character sheet manager with a solid backend, a functioning security layer, a tested API, and an automated deployment pipeline. The gap between vision and implementation is real, but it is not a failure — it is scope management.\nThe domain is clean. The architecture is layered and consistent. The decisions made along the way are documented and defensible. For a first solo backend project of this scale, that feels like the right outcome.\nTechnical Decisions in Hindsight # A few decisions stand out on reflection. Keeping associations unidirectional by default added discipline to the entity design and kept the dependency graph manageable. Implementing content hashing for reference data sync was more work than a simple delete-and-reinsert approach, but it is the kind of solution that holds up under scrutiny. Writing tests derived from acceptance criteria rather than chasing coverage numbers meant that every test has a reason to exist.\nThe JWT fail-fast pattern is a small detail that I am disproportionately satisfied with. A missing environment variable should never surface as a cryptic runtime error. Crashing loudly at startup is the right behaviour.\nWhat Comes Next # Campaign management, character portrait uploads, and a frontend are the natural next steps. Whether they become part of this project or a separate one is an open question. The backend is built to support them — the domain model accounts for campaigns and membership, and the architecture is clean enough that extending it should not require significant rework.\nFor now, the focus is the exam. The goal is to walk through the codebase with confidence — not because every decision was perfect, but because every decision was made deliberately.\n","date":"3 April 2026","externalUrl":null,"permalink":"/posts/devlog-8/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 8","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"27 March 2026","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"","date":"27 March 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"DevOps","type":"tags"},{"content":"","date":"27 March 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":" Where We Left Off # The application is feature-complete for the current scope. This week the focus shifted to deployment — getting Sheet Herder running in a production environment and setting up a pipeline that makes future deployments effortless.\nCI/CD with GitHub Actions # Every push to main triggers a GitHub Actions pipeline. The pipeline builds the project, runs the full test suite, and if everything passes, pushes a new Docker image to Docker Hub. If any step fails, nothing is deployed.\nThis is the kind of feedback loop that changes how you work. Knowing that a passing push is automatically deployed removes the friction between writing code and seeing it live.\nDocker and Caddy # The application runs in Docker. Caddy handles TLS termination and reverse proxying, and obtains SSL certificates automatically. The setup uses separate docker-compose.yml files for each concern — Postgres, Caddy, Sheet Herder and Watchtower each have their own stack. This keeps them independently manageable and easy to reason about.\nWatchtower and Webhooks # Watchtower watches the Sheet Herder container and redeploys it when a new image is available. The default polling interval of 60 seconds would have been sufficient, but I chose to implement a webhook trigger instead — partly because near-instant redeployment is a better experience, and partly because working with webhooks was something I had not done before in a deployment context.\nThe webhook is triggered by the GitHub Actions pipeline via a restricted SSH key after a successful push to Docker Hub. The result is that a passing build is live on the server in under a minute.\nCurrent State # Sheet Herder is live. The pipeline is working, deployments are automatic, and the infrastructure is stable. The final week is about wrapping up, reflecting, and preparing for the exam.\n","date":"27 March 2026","externalUrl":null,"permalink":"/posts/devlog-7/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 7","type":"posts"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/bcrypt/","section":"Tags","summary":"","title":"BCrypt","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/jwt/","section":"Tags","summary":"","title":"JWT","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/security/","section":"Tags","summary":"","title":"Security","type":"tags"},{"content":" Where We Left Off # The API is tested and the core functionality is working. This week the focus shifted to security — which in a backend application means two things: who you are, and what you are allowed to do.\nPassword Hashing with BCrypt # BCrypt is the standard choice for password hashing, and for good reason. It is slow by design — the cost factor controls how computationally expensive the hash is, making brute force attacks progressively less practical as the cost is raised.\nOne detail worth addressing: BCrypt has a 72-byte input limit. A password longer than 72 bytes is silently truncated, which means two different passwords could produce the same hash. The solution is to prehash the password with SHA-256 before passing it to BCrypt — this produces a fixed-length Base64-encoded string regardless of the original password length, ensuring full entropy is preserved.\nJWT Authentication # Authentication is implemented using JWT tokens. On login, a signed token is issued containing the user\u0026rsquo;s identity and roles. The token must be included in subsequent requests and is validated on every protected endpoint.\nThe implementation uses Nimbus JOSE+JWT — a well-maintained library that handles the cryptographic details. The wrapper around it is kept minimal and focused: sign a token, validate a token, extract claims.\nA detail I am particularly satisfied with: missing JWT_SECRET or JWT_ISSUER environment variables cause the application to crash immediately on startup. A missing configuration should surface as an explicit error at startup, not as a cryptic failure at runtime.\nRole-Based Authorization # Authorization is enforced through Javalin before-filters. Every route declares its required role explicitly. Public endpoints use Role.ANYONE — not the absence of a role check, but an explicit statement of intent. This makes it immediately clear when reading a route definition whether authentication is required.\nCurrent State # The security layer is in place. Authentication works, authorization is enforced, and passwords are handled correctly. The next step is deployment — getting the application running in a production environment.\n","date":"20 March 2026","externalUrl":null,"permalink":"/posts/devlog-6/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 6","type":"posts"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/rest-assured/","section":"Tags","summary":"","title":"REST Assured","type":"tags"},{"content":" Where We Left Off # The API layer is in place. This week the focus shifted to testing — which in this project means more than just confirming that endpoints return 200.\nREST Assured and Hamcrest # REST Assured makes it possible to write integration tests that read almost like plain English. Combined with Hamcrest matchers, assertions are expressive and failure messages are readable. The combination feels natural once you settle into the syntax.\nGherkin Syntax # Gherkin introduced a structured way of thinking about test cases: Given a starting state, When an action is performed, Then an expected outcome follows. This maps directly onto the acceptance criteria already written for each user story, which made the transition from specification to test case straightforward.\nTestcontainers # Testing against an in-memory database gives fast feedback but tells you nothing about how the application behaves against a real one. Testcontainers solves this by spinning up a real PostgreSQL instance for each test run. The tests run in CI exactly as they would locally, and the results are meaningful.\nWriting Tests With Purpose # Tests in this project are not written to achieve coverage numbers. They are written to verify that the system behaves correctly according to its specification. The process started with the acceptance criteria and business rules — these were used to derive concrete test cases, and some tests reference a specific user story or business rule directly.\nThis approach forced a clarity of intent that I found valuable. If a test cannot be traced back to a requirement, it raises the question of what it is actually testing.\nWebhooks and Websockets # This week also briefly covered webhooks and websockets. Webhooks in particular became directly relevant — the CI/CD pipeline later uses a Watchtower webhook to trigger redeployment after a successful build.\nCurrent State # The test suite covers the core functionality and reflects the business rules and acceptance criteria. The next step is security — authentication and authorization.\n","date":"13 March 2026","externalUrl":null,"permalink":"/posts/devlog-5/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 5","type":"posts"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/testcontainers/","section":"Tags","summary":"","title":"Testcontainers","type":"tags"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/testing/","section":"Tags","summary":"","title":"Testing","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/javalin/","section":"Tags","summary":"","title":"Javalin","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/logging/","section":"Tags","summary":"","title":"Logging","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/rest/","section":"Tags","summary":"","title":"REST","type":"tags"},{"content":" Where We Left Off # Reference data is fetched, persisted, and synced at startup. This week the focus shifted to the API layer — the part of the application the outside world actually talks to.\nREST Principles # This week started with the guiding principles behind RESTful API design. A few concepts stood out as particularly important for how I approached the implementation.\nIdempotence is worth pausing on. An operation is idempotent if calling it multiple times produces the same result as calling it once. GET, PUT, and DELETE are idempotent. POST is not. This distinction matters when designing endpoints, especially when thinking about what happens if a request is retried.\nResource naming is another area where REST has strong conventions. URIs identify resources, not actions. /character-sheets/{id} is a resource. /getCharacterSheet?id=1 is not. Following these conventions makes the API predictable and easy to consume.\nJavalin and the API Layer # The API layer is built with Javalin, a lightweight Java web framework. Each concern in ApplicationConfig is handled by a dedicated private method — routing, exception handling, JSON serialisation, and logging each have a clear home.\nThe startup sequence reflects this structure:\npublic static Javalin startServer(int port) { ExecutionTimer.start(); JWTUtil.validate(); DIContainer diContainer = DIContainer.getInstance(); DataSeeder.seed(diContainer); Routes routes = buildRoutes(diContainer); Javalin app = Javalin.create(config -\u0026gt; { configureRoutes(config, routes); configureExceptions(config); configureJackson(config, diContainer); configureLogger(config); }).start(port); ExecutionTimer.finish(\u0026#34;SheetHerder ready on port \u0026#34; + port); return app; } ExecutionTimer wraps the entire startup process. Split points are recorded at meaningful milestones — after reference data is fetched, after it is synchronised — and the total time is logged when the server is ready. In practice this looks something like:\nReference data fetched: 00:01.24 (total: 00:01.24) Reference data synchronized: 00:00.43 (total: 00:01.67) SheetHerder ready on port 7070: 00:02.14 This makes slow startups immediately visible rather than something you notice by chance.\nDependency Injection # DIContainer is a manually managed singleton that instantiates and wires together every component in the application — DAOs, services, controllers, and the HTTP client. There is no framework handling this. Every dependency is constructed explicitly and injected through constructors.\nThis is a deliberate choice. With a manual container, the dependency relationships are visible and explicit. At exam time, being able to explain exactly how a controller gets its service, and how that service gets its DAO, is straightforward because there is nothing implicit about it.\nRoutes and Controllers # Routes define the endpoints. Controllers handle the HTTP concerns and delegate to services. The two are kept deliberately separate.\nLanguageRoute shows the pattern:\npublic class LanguageRoute { private final IReferenceController languageController; protected EndpointGroup getRoutes() { return () -\u0026gt; path(\u0026#34;languages\u0026#34;, () -\u0026gt; { get(languageController::getAll); get(\u0026#34;{id}\u0026#34;, languageController::getById); get(\u0026#34;name/{name}\u0026#34;, languageController::getByName); }); } } LanguageController handles the HTTP layer and nothing else:\npublic class LanguageController implements IReferenceController { @Override public void getById(Context ctx) { Long id = Long.parseLong(ctx.pathParam(\u0026#34;id\u0026#34;)); LanguageDTO languageDTO = languageService.getById(id) .orElseThrow(() -\u0026gt; new NotFoundException(\u0026#34;Language with id \u0026#34; + id + \u0026#34; not found\u0026#34;)); ctx.status(HttpStatus.OK).json(languageDTO); } @Override public void getAll(Context ctx) { ctx.status(HttpStatus.OK).json(languageService.getAll()); } } The controller parses the request, calls the service, and writes the response. Business logic does not belong here, and there is none. Both route and controller implement interfaces — IReferenceController defines the contract, and every reference type gets its own implementation. Adding a new reference type means implementing the interface, not redesigning the layer.\nException Handling # Exceptions in this application are purpose-specific. NotFoundException, ValidationException, and ConflictException all extend ApiException, which carries an HTTP status code alongside the message. When something goes wrong, the exception already knows what status code to return.\nApplicationConfig maps these to HTTP responses in one place:\nconfig.routes.exception(ApiException.class, (e, ctx) -\u0026gt; { log.warn(\u0026#34;{} {} - {}\u0026#34;, ctx.method(), ctx.path(), e.getMessage()); ctx.status(e.getCode()) .json(Map.of( \u0026#34;status\u0026#34;, e.getCode(), \u0026#34;message\u0026#34;, e.getMessage() )); }); config.routes.exception(NumberFormatException.class, (e, ctx) -\u0026gt; { log.warn(\u0026#34;{} {} - Invalid number format: {}\u0026#34;, ctx.method(), ctx.path(), e.getMessage()); ctx.status(HttpStatus.BAD_REQUEST.getCode()) .json(Map.of( \u0026#34;status\u0026#34;, HttpStatus.BAD_REQUEST.getCode(), \u0026#34;message\u0026#34;, \u0026#34;Invalid ID format: expected a number\u0026#34; )); }); config.routes.exception(Exception.class, (e, ctx) -\u0026gt; { log.error(\u0026#34;{} {} - Unhandled exception\u0026#34;, ctx.method(), ctx.path(), e); ctx.status(HttpStatus.INTERNAL_SERVER_ERROR.getCode()) .json(Map.of( \u0026#34;status\u0026#34;, HttpStatus.INTERNAL_SERVER_ERROR.getCode(), \u0026#34;message\u0026#34;, \u0026#34;Internal server error\u0026#34; )); }); NumberFormatException is handled explicitly because Long.parseLong in getById will throw it if a caller passes a non-numeric id. Without this handler, a request to /languages/abc would fall through to the generic 500 handler, which would be the wrong response for what is ultimately a bad request.\nAny other unhandled exception returns a generic 500 with no internal details exposed. The full stack trace is logged, but the response tells the caller only that something went wrong. Internal errors should be visible to the developer through logs, not to the caller through the API response.\nLogging # Logging is configured with Logback and SLF4J. The configuration grew beyond what was strictly necessary, partly because I found it genuinely interesting to work through.\nThe log level is environment-driven — ${LOG_LEVEL:-INFO} defaults to INFO but can be overridden at runtime without rebuilding. This matters in a deployed environment where you might want to temporarily raise verbosity without touching the application.\nTwo separate RollingFileAppender instances write to different files for different purposes. The application log captures everything at the configured level and above. The error log captures only ERROR level and above, kept separately so errors are easy to find without scanning through general output. Both files are compressed to .gz on rollover, which keeps disk usage manageable over time.\nThe rolling policies differ deliberately. Application logs roll daily and are kept for 30 days with a 1GB total size cap — frequent enough to keep individual files small, with enough history to trace recent behaviour. Error logs roll weekly and are kept for 13 weeks, roughly a quarter. Errors are less frequent but more important to retain, and a weekly file makes it easy to correlate errors with a specific period.\nThird-party frameworks are capped at WARN to avoid log noise from Hibernate, Testcontainers, Jetty, and HikariCP. HikariCP is the one exception — connection pool startup is logged at INFO because it is useful to see during startup.\nThe request logger excludes the health check endpoint:\nconfig.requestLogger.http((ctx, ms) -\u0026gt; { if (ctx.path().equals(\u0026#34;/api/v1/health-check\u0026#34;)) return; if (ctx.status().getCode() \u0026gt;= 500) { log.error(\u0026#34;{} {} - {} ({}ms)\u0026#34;, ctx.method(), ctx.path(), ctx.status(), ms.longValue()); } else if (ctx.status().getCode() \u0026gt;= 400) { log.warn(\u0026#34;{} {} - {} ({}ms)\u0026#34;, ctx.method(), ctx.path(), ctx.status(), ms.longValue()); } else { log.info(\u0026#34;{} {} - {} ({}ms)\u0026#34;, ctx.method(), ctx.path(), ctx.status(), ms.longValue()); } }); Docker health checks poll the endpoint every few seconds. Without the exclusion, those requests would flood the logs and make it harder to see what is actually happening.\nCurrent State # The core API endpoints are in place. The next step is testing — making sure the API behaves correctly under the conditions defined by the acceptance criteria and business rules.\n","date":"6 March 2026","externalUrl":null,"permalink":"/posts/devlog-4/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 4","type":"posts"},{"content":"","date":"27 February 2026","externalUrl":null,"permalink":"/tags/api-integration/","section":"Tags","summary":"","title":"API Integration","type":"tags"},{"content":"","date":"27 February 2026","externalUrl":null,"permalink":"/tags/dto/","section":"Tags","summary":"","title":"DTO","type":"tags"},{"content":"","date":"27 February 2026","externalUrl":null,"permalink":"/tags/executorservice/","section":"Tags","summary":"","title":"ExecutorService","type":"tags"},{"content":" Where We Left Off # The DAO layer is in place and the domain entities are mapped. This week the focus shifted outward — fetching reference data from an external API and integrating it into the persistence layer.\nThe D\u0026amp;D 5e API # The D\u0026amp;D 5e REST API provides all the reference data needed for character creation. The decision to fetch this data at startup and persist it locally was deliberate. It avoids a runtime dependency on an external service and gives the application full control over the data it works with. If the API is unavailable after the first run, the application still functions.\nFetching on every startup is a somewhat redundant approach — in a production setting, a scheduled job or a webhook triggered by upstream data changes would be the appropriate design. I am aware of that. The startup fetch is kept here because it is predictable, easy to reason about, and honest about where my skills are right now. It is a design choice I can explain and defend, not one I arrived at by accident.\nThe integration follows a two-step pattern. A first request fetches a summary list — names and URLs, nothing more. A second round of requests fetches the full detail for each entry. For languages, that looks like this:\n// Step 1: summary list public record DNDLanguageResultDTO( @JsonProperty(\u0026#34;count\u0026#34;) int count, @JsonProperty(\u0026#34;results\u0026#34;) List\u0026lt;DNDLanguageDTO\u0026gt; languages ) {} // Step 2: summary entry with URL for detail fetch public record DNDLanguageDTO( @JsonProperty(\u0026#34;index\u0026#34;) String index, @JsonProperty(\u0026#34;name\u0026#34;) String name, @JsonProperty(\u0026#34;url\u0026#34;) String url ) {} // Step 3: full detail public record DNDLanguageDetailDTO( @JsonProperty(\u0026#34;name\u0026#34;) String name, @JsonProperty(\u0026#34;desc\u0026#34;) String description, @JsonProperty(\u0026#34;type\u0026#34;) String type, @JsonProperty(\u0026#34;typical_speakers\u0026#34;) List\u0026lt;String\u0026gt; typicalSpeakers, @JsonProperty(\u0026#34;script\u0026#34;) String script ) {} @JsonIgnoreProperties(ignoreUnknown = true) is applied to all external DTOs. The API returns more fields than the application needs, and ignoring unknown properties means the mapping does not break if the API adds or changes fields.\nThe separation between summary and detail DTOs is intentional. The domain model should not be shaped by the structure of an external API. These DTOs model the external response exactly as it arrives. The service layer is responsible for mapping them into domain entities before anything is persisted.\nConcurrent Fetching # Fetching detail records sequentially would mean one HTTP request per entry, one after another. For a dataset the size of D\u0026amp;D traits — over a hundred entries — that adds up. Instead, each detail fetch is submitted as a concurrent task and the results are collected once all are complete:\nList\u0026lt;Callable\u0026lt;DNDLanguageDetailDTO\u0026gt;\u0026gt; tasks = resultDTO.languages().stream() .map(language -\u0026gt; { String url = String.format(BASE_URL, language.url()); return (Callable\u0026lt;DNDLanguageDetailDTO\u0026gt;) () -\u0026gt; dndClient.fetchLanguageDetails(url); }) .toList(); return ThreadUtil.fetchConcurrently(tasks); ThreadUtil.fetchConcurrently submits all tasks to a fixed thread pool, collects the futures, and waits for each result:\npublic static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; fetchConcurrently(List\u0026lt;Callable\u0026lt;T\u0026gt;\u0026gt; tasks) { ExecutorService executorService = Executors.newFixedThreadPool(10); List\u0026lt;T\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(); List\u0026lt;Future\u0026lt;T\u0026gt;\u0026gt; futures = tasks.stream() .map(executorService::submit) .toList(); try { for (Future\u0026lt;T\u0026gt; future : futures) { result.add(future.get()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ConcurrentExecutionException(\u0026#34;Failed to fetch data concurrently\u0026#34;, e); } catch (ExecutionException e) { throw new ConcurrentExecutionException(\u0026#34;Failed to fetch data concurrently\u0026#34;, e); } finally { executorService.shutdown(); } return result; } The difference in startup time is noticeable. More importantly, it introduced a pattern worth understanding: submit tasks, collect futures, handle results. The executor is always shut down in the finally block regardless of outcome.\nContent Hashing and Sync # Fetching reference data at startup raises a question: what happens on subsequent startups? Deleting and reinserting every record on each run would be wasteful and would break any foreign key relationships that have built up. The approach taken is to detect changes and only write what has actually changed.\nEach reference entity stores a contentHash field — a SHA-256 hash computed from its fields at the time it was fetched. On every startup, the incoming hash is compared against the stored one. If they match, the record is skipped. If they differ, it is updated. If no record exists yet, it is inserted:\nprivate Language resolveLanguage(EntityManager em, Language incoming, Language existing) { if (existing == null) { em.persist(incoming); return incoming; } if (incoming.getContentHash().equals(existing.getContentHash())) { return existing; } return applyUpdate(existing, incoming); } The hash itself is computed from a normalised string representation of the fields that matter:\npublic static String sha256Hex(String input) { MessageDigest messageDigest = MessageDigest.getInstance(\u0026#34;SHA-256\u0026#34;); byte[] hashBytes = messageDigest.digest(input.getBytes(StandardCharsets.UTF_8)); return toHex(hashBytes); } This makes the sync operation idempotent. Running it once or a hundred times produces the same result. The hash is the source of truth for whether a record needs updating, not a field-by-field comparison.\nFetch Types # With reference data now in the application, the fetch type decisions made earlier became immediately testable. The first attempt to load a persisted Race and display it in the terminal produced a LazyInitializationException on the associated languages and traits.\nThe cause is a mismatch between when Hibernate loads data and when the session is open. With FetchType.LAZY, associations are only fetched while the EntityManager is still open. Once it is closed, any attempt to access an unloaded association fails. For reference data, this is a poor fit. Races, subraces, languages, and traits are always needed together. Deferring their loading adds complexity with no real benefit.\nThe decision was to switch all reference entity associations to FetchType.EAGER. Everything loads upfront in a single operation. For a dataset of this size and access pattern, this feels like the right tradeoff — at least one I can reason about and defend.\nEven with EAGER configured, there is a subtlety worth addressing. By default, Hibernate loads eager associations with separate queries — one for the parent record, one for each associated collection. For a list of races, that means one query for the races themselves, then additional queries for each race\u0026rsquo;s languages and traits. The number of queries grows with the number of records.\nLEFT JOIN FETCH in JPQL overrides this and loads everything in a single query:\nreturn em.createQuery(\u0026#34;\u0026#34;\u0026#34; SELECT DISTINCT r FROM Race r LEFT JOIN FETCH r.languages LEFT JOIN FETCH r.traits \u0026#34;\u0026#34;\u0026#34;, Race.class) .getResultList(); SELECT DISTINCT is necessary because the join produces one row per combination of race, language, and trait. Without it, the same race appears multiple times in the result list.\nCurrent State # Reference data is fetched at startup, persisted locally, and kept current through hash-based change detection. The character sheet now has something meaningful to reference. The next step is building the REST API layer on top of what has been built so far.\n","date":"27 February 2026","externalUrl":null,"permalink":"/posts/devlog-3/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 3","type":"posts"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/hibernate/","section":"Tags","summary":"","title":"Hibernate","type":"tags"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/jpa/","section":"Tags","summary":"","title":"JPA","type":"tags"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/relations/","section":"Tags","summary":"","title":"Relations","type":"tags"},{"content":" Where We Left Off # Last week the domain was defined and the first entities were in place with basic field mappings. This week the focus shifted to JPA relations, JPQL, and building out the DAO layer. More groundwork than visible output, but necessary groundwork.\nJPA Relations # Adding relations to the entity layer was straightforward once the domain model was in place. The decisions about which associations to model were already made. What required more thought was how to model them.\nThe default approach was to keep associations unidirectional. If an entity does not need to navigate to another, it should not hold a reference to it. Bidirectional relationships were only introduced where the domain genuinely required navigation in both directions. This keeps coupling low and the codebase easier to reason about.\nCascade behaviour is where business rules translate directly into persistence configuration. BR-6 states that deleting a user removes all their character sheets. On User, this maps to CascadeType.REMOVE and orphanRemoval = true on the characterSheets collection:\n@OneToMany(mappedBy = \u0026#34;user\u0026#34;, cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) private List\u0026lt;CharacterSheet\u0026gt; characterSheets = new ArrayList\u0026lt;\u0026gt;(); CascadeType.REMOVE handles deletion of the parent. When a User is deleted, Hibernate removes all associated CharacterSheet records automatically. orphanRemoval = true handles the additional case where a character sheet is removed from the collection without the user being deleted. Together they cover the business rule completely.\nJPQL # JPQL is Hibernate\u0026rsquo;s query language. Where SQL operates against tables and columns, JPQL operates against the object model — entity class names and field names rather than table and column names. The practical benefit is that queries remain decoupled from the underlying schema.\nA simple example from UserDAO:\nreturn em.createQuery(\u0026#34;\u0026#34;\u0026#34; SELECT u FROM User u WHERE LOWER(u.email) = LOWER(:email) \u0026#34;\u0026#34;\u0026#34;, User.class) .setParameter(\u0026#34;email\u0026#34;, email) .getResultStream() .findFirst(); getResultStream().findFirst() returns an Optional rather than throwing an exception when no result is found, which is the appropriate behaviour for a lookup that may legitimately return nothing. getSingleResult() would throw a NoResultException on an empty result — useful in some contexts, but not here. The case-insensitive comparison using LOWER() on both sides ensures that email lookups are consistent regardless of how the address was entered.\nThe DAO Layer # Every entity type gets its own DAO, and every DAO implements a shared interface. IDAO\u0026lt;T\u0026gt; defines the base contract:\npublic interface IDAO\u0026lt;T\u0026gt; { T create(T t); T getById(Long id); T update(T t); Long delete(Long id); } Domain DAOs extend this with operations specific to their entity. IUserDAO adds user lookup by email and username, and role management. ICharacterSheetDAO adds retrieval by owner:\npublic interface IUserDAO extends IDAO\u0026lt;User\u0026gt; { Optional\u0026lt;User\u0026gt; getByEmail(String email); Optional\u0026lt;User\u0026gt; getByUsername(String username); User addRole(Long id, Role role); User removeRole(Long id, Role role); } public interface ICharacterSheetDAO extends IDAO\u0026lt;CharacterSheet\u0026gt; { List\u0026lt;CharacterSheet\u0026gt; getAllByUser(User user); } UserDAO is the more complete implementation at this stage. CharacterSheetDAO is intentionally sparse. CharacterSheet references race, subrace, languages, and ability scores, none of which exist as data yet. The DAO is in place and the contract is defined, but meaningful queries against character sheet content have to wait until the reference layer is populated.\nScope # As the DAO layer took shape, it became increasingly clear that Campaign and CampaignMembership would not make it into this exam submission. The domain model accounts for them. The entities exist, the business rules are defined. But building out campaign management would pull focus from the core of what the application needs to demonstrate. That boundary was not drawn in a single moment, but by this point it had become a working assumption. Campaign features are out of scope for this prototype.\nCurrent State # Relations are mapped, cascade behaviour reflects the business rules, and the core domain DAOs are in place. The reference DAO layer is stubbed and ready, but mostly waiting. The next step is fetching data from the D\u0026amp;D 5e API, which is when the reference layer comes to life and CharacterSheet starts to fill out properly.\n","date":"20 February 2026","externalUrl":null,"permalink":"/posts/devlog-2/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 2","type":"posts"},{"content":"","date":"13 February 2026","externalUrl":null,"permalink":"/tags/domain-model/","section":"Tags","summary":"","title":"Domain Model","type":"tags"},{"content":" Where We Left Off # Last week I committed to developing Sheet Herder. Since then, most of the work has been analytical rather than technical. Before writing a single line of persistence code, I wanted to be confident in the domain. That turned out to be the right call.\nDomain Model # The domain model for this project is intentionally simple. There are four core entities: User, CharacterSheet, Campaign, and CampaignMembership. The ERD will grow once reference data comes into the picture, but the core has to be right first.\nThe most interesting design challenge was roles. A user is not simply a player or a game master — they can be both, depending on the campaign. Modelling this as a field on User would have been wrong. A user does not have a role. A user has a role within a campaign. That distinction forced the introduction of CampaignMembership as an explicit entity rather than a simple join table.\nCampaignMembership maps a user to a campaign, holds their role in that campaign (GAME_MASTER or PLAYER), and optionally references the character sheet they are participating with. The unique constraint on (user_id, campaign_id) enforces at the database level that a user can only hold one membership per campaign.\n@Entity @Table(uniqueConstraints = @UniqueConstraint( name = \u0026#34;uk_user_membership_campaign\u0026#34;, columnNames = {\u0026#34;user_id\u0026#34;, \u0026#34;campaign_id\u0026#34;} )) public class CampaignMembership implements IEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = \u0026#34;user_id\u0026#34;, nullable = false) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = \u0026#34;campaign_id\u0026#34;, nullable = false) private Campaign campaign; @OneToOne(optional = true) @JoinColumn(name = \u0026#34;character_sheet_id\u0026#34;, unique = true) private CharacterSheet characterSheet; @Enumerated(EnumType.STRING) @Column(nullable = false) private CampaignRole role; } CharacterSheet is where most of the domain complexity lives. A character belongs to exactly one user, has a name unique within that user\u0026rsquo;s collection, and references reference data — race, subrace, and a set of languages — alongside ability scores and notes stored as element collections.\n@Entity @Table(name = \u0026#34;character_sheet\u0026#34;, uniqueConstraints = @UniqueConstraint( name = \u0026#34;uk_user_character_name\u0026#34;, columnNames = {\u0026#34;user_id\u0026#34;, \u0026#34;name\u0026#34;} )) public class CharacterSheet implements IEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = \u0026#34;user_id\u0026#34;, nullable = false) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = \u0026#34;race_id\u0026#34;) private Race race; @ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = \u0026#34;character_languages\u0026#34;, joinColumns = @JoinColumn(name = \u0026#34;character_id\u0026#34;), inverseJoinColumns = @JoinColumn(name = \u0026#34;language_id\u0026#34;) ) private Set\u0026lt;Language\u0026gt; languages; @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = \u0026#34;character_ability_scores\u0026#34;, joinColumns = @JoinColumn(name = \u0026#34;character_id\u0026#34;)) @MapKeyEnumerated(EnumType.STRING) @Column(name = \u0026#34;score\u0026#34;) private Map\u0026lt;Ability, Integer\u0026gt; abilityScores; } User owns character sheets and cascades deletion to them. Campaign at this stage is a minimal scaffold — it exists to anchor the relationships and will expand when campaign management comes into scope. Both carry createdAt and updatedAt fields managed by @PrePersist and @PreUpdate lifecycle hooks, keeping timestamp logic out of the service layer.\nReference Data # One part of the domain model that required early scoping decisions was reference data — races, subraces, languages, and traits. The D\u0026amp;D 5e API offers significantly more than this: classes, spells, equipment, and more. Prior research and existing domain knowledge made it straightforward to identify what a character sheet needs at a minimum and what falls beyond that.\nFor this prototype, the scope is deliberately narrow. Races, subraces, languages, and traits cover the core of character creation without overcomplicating the integration work. Classes, spells, and the rest are not out of reach — the domain model is designed to accommodate them — but they are out of scope for now. That boundary was drawn consciously; scope management rather than limitation.\nLombok and JPA Equality # One issue that came up while mapping entities: Lombok\u0026rsquo;s @EqualsAndHashCode annotation generates equals and hashCode methods based on the fields of a class. That sounds reasonable until you factor in how Hibernate works under the hood.\nWhen Hibernate loads an associated entity lazily — deferring the database call until the data is actually accessed — it does not give you the real object immediately. Instead, it gives you a placeholder that looks and behaves like the real one but is essentially an empty wrapper until it is touched. The problem is that Lombok\u0026rsquo;s generated equals compares class types directly, and this placeholder\u0026rsquo;s class is not the same as the actual entity class. Two objects that represent the same database record can therefore compare as unequal, which causes subtle and hard-to-trace bugs in collections, caches, and anywhere else equality matters.\nThe correct approach is to resolve what the real underlying class is before comparing — and to base equality solely on the database identifier rather than object state. This is not something I arrived at independently; it is what IntelliJ generates when asked to produce equals and hashCode for a JPA entity, and I added a comment to each entity to make that explicit. All domain entities in this project use this pattern:\n@Override public final boolean equals(Object object) { if (this == object) return true; if (object == null) return false; Class\u0026lt;?\u0026gt; oEffectiveClass = object instanceof HibernateProxy ? ((HibernateProxy) object).getHibernateLazyInitializer().getPersistentClass() : object.getClass(); Class\u0026lt;?\u0026gt; thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); if (thisEffectiveClass != oEffectiveClass) return false; CampaignMembership that = (CampaignMembership) object; return getId() != null \u0026amp;\u0026amp; Objects.equals(getId(), that.getId()); } @Override public final int hashCode() { return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); } An unpersisted entity — one with no id yet — is not equal to anything, including itself. That is intentional. Until a record exists in the database, there is no identity to compare.\nBusiness Rules # Defining business rules had more impact on the design than expected. They establish the boundaries of the system — what is allowed, what is not, and who is responsible for what. Getting them right early made later decisions significantly easier to reason about.\nA few rules shaped the persistence layer directly. Two of them define cascade deletion behaviour: deleting a user removes all their character sheets; deleting a user who owns a campaign removes the campaign and all associated data. These rules directly inform the Hibernate cascade configuration — the domain rules and the persistence behaviour have to match. A business rule that is not enforced at the database level is not really a rule.\nAnother rule states that a character cannot participate in multiple campaigns simultaneously. This is reflected in the unique = true constraint on character_sheet_id in CampaignMembership. The uniqueness is not just an application-level check — the database enforces it.\nThe permission rules define visibility boundaries that will drive authorization logic later. A game master can see full character details for all characters in their campaign. A player sees only limited public information for others. Character notes are entirely private, even from the game master.\nUser Stories and Definition of Done # The user stories are intentionally broad. As a solo contributor, fully decomposed tasks add overhead without adding value. They define scope and serve as the basis for acceptance criteria, which will drive how tests are written later.\nOne observation from writing them: the stories map naturally to roles. The player-facing stories cover authentication, character creation and management, and notes. The game master stories cover campaign creation, membership management, and session logs. There is also a cross-cutting story about reference data access during character creation. That split reflects the domain well and makes it straightforward to reason about access control when the time comes.\nThe Definition of Done is short and deliberate — an internal agreement that keeps scope honest. The most important item is the last: a feature must integrate without breaking what already works. In a project built bottom-up, that means every layer has to be verifiable before the next is built on top of it.\nJava Persistence API # This week introduced JPA with a focus on ORM, entity mapping, and DAOs. Coming from a solid understanding of JDBC and raw SQL, the abstraction layer took some adjustment. The mental shift from writing queries to mapping objects is significant, and I spent time understanding what Hibernate was doing under the hood rather than treating it as a black box. That paid off almost immediately when the equality issue came up.\nThe implementation at this stage is intentionally lean — basic field mappings, no relations yet. Relations are next, and they will be the first real test of whether the domain model holds up in practice.\nCurrent State # The domain is defined, the rules are documented, and the first entities are in place. The next step is JPA relations, cascade behaviour, and fetch types — which will determine how well the domain model translates into a working persistence layer.\n","date":"13 February 2026","externalUrl":null,"permalink":"/posts/devlog-1/","section":"Posts","summary":"","title":"Sheet Herder - Devlog 1","type":"posts"},{"content":" About Me # I am a third-semester student enrolled in the Datamatiker programme at Erhvervsakademi København — an Academy Profession Degree in Computer Science.\nI started the programme in January 2025 with no prior programming experience. Since then, the focus has been on building a solid foundation in backend development, gradually working towards more complex architecture, testing, and deployment.\nThis portfolio documents that progression — not just the finished products, but the decisions, missteps, and lessons along the way.\nAbout This Site # This site serves two purposes. It is a requirement of the programme to maintain a portfolio with weekly devlogs throughout the semester. Beyond that, it is a space I intend to keep building on — a place to document projects, share technical reflections, and track growth over time.\nThe content here is primarily technical, but I try to write in a way that is honest about the process rather than just presenting polished outcomes.\nTech Stack # The projects documented here are primarily built with:\nJava 17, Maven Javalin, JPA / Hibernate, PostgreSQL Docker, Caddy, DigitalOcean GitHub Actions (CI/CD) JUnit, REST Assured, Testcontainers Contact # GitHub: github.com/DHangaard ","date":"6 February 2026","externalUrl":null,"permalink":"/about/","section":"DHangaard","summary":"","title":"About","type":"page"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/portfolio/","section":"Tags","summary":"","title":"Portfolio","type":"tags"},{"content":"Welcome to my project portfolio. This section highlights the software and backend systems I have been building, with a focus on practical architecture, clean implementation, and steady iteration.\n","date":"6 February 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/rest-api/","section":"Tags","summary":"","title":"REST API","type":"tags"},{"content":" Project Overview # Project: Portfolio \u0026amp; Exam Submission\nSemester: DAT 3rd Semester 2026\nFocus: Production-ready Java backend with authentication, role-based access control, external API integration, and automated deployment.\nSheet Herder is a backend API for a Tabletop Roleplaying Game (TTRPG) companion application. The system allows players to create and manage their characters and personal notes, while game masters can organise campaigns, track session progress, and keep an eye on their party — all in one place.\nVision # Players manage characters and notes. Game masters create campaigns, track sessions, and oversee their party. One API to power it all.\nThe longer-term vision is a rules-agnostic companion that supports any TTRPG system. The current implementation is built around the D\u0026amp;D 5e ruleset, using the D\u0026amp;D 5e REST API as its reference data source.\nArchitecture # Sheet Herder is implemented as a layered backend architecture with clear separation between HTTP handling, business logic, and persistence.\nThe system consists of:\nJavalin controllers handling REST communication Service layer implementing domain logic and business rules DAO layer using JPA/Hibernate for database access PostgreSQL relational data model JWT authentication and role-based authorization D\u0026amp;D 5e REST API integration for reference data (races, subraces, languages, traits) Docker containerisation with Caddy as reverse proxy and automatic TLS Automated CI/CD pipeline via GitHub Actions, Docker Hub and Watchtower Development Log # The project is documented week by week in the devlog series.\nDevlog 0 - Initial Commit Devlog 1 - Domain modelling, business rules, user stories and first steps with JPA Devlog 2 - JPA relations, fetch types and the first real test of the domain model Devlog 3 - Fetching reference data from the D\u0026amp;D 5e API, threading with ExecutorService and mapping with DTOs Devlog 4 - REST principles, HTTP methods, status codes, logging and building the API layer with Javalin Devlog 5 Devlog 6 Devlog 8 Devlog 9 Project Video # A short walkthrough of the Sheet Herder portfolio and backend system.\nThe video covers the project overview, development log, architecture, and a live backend demo.\nVideo Demo\nSource Code # dhangaard/sheet-herder-api Java 0 0 Live API # The deployed application is available at sheet-herder-api.dhangaard.dk.\n","date":"6 February 2026","externalUrl":null,"permalink":"/projects/sheet-herder/","section":"Projects","summary":"","title":"Sheet Herder","type":"projects"},{"content":" Initial Commit # To anyone reading, my name is Daniel, and this is the very first blog post on my very first website.\nThis is all very much outside my comfort zone. I am a private person by nature — I do not use social media, I rarely use my full name publicly, and I am the kind of person who removes name labels from packages and letters before discarding the packaging. Writing publicly, even in a technical context, does not come naturally to me. As an exercise in self-development, I will try to be as honest and personal throughout this site as possible.\nThis post is not intended to serve as an \u0026ldquo;About Me\u0026rdquo; section, but given that this is the starting point of the site, a short introduction feels appropriate.\nI am in my mid-thirties and currently enrolled in the Datamatiker programme at Erhvervsakademi København. The English equivalent of this education is an Academy Profession Degree in Computer Science. I am currently on my third semester out of a total of five.\nBefore starting the Datamatiker programme in January 2025, I had no prior experience with programming. I mention this for context, as this devlog will document not only what I build, but also how I learn, think, and sometimes overthink along the way.\nIf you would like to know more, you can visit the About page.\nThis Portfolio # During this semester of the programme, we are required to develop a portfolio website. Beyond serving as a learning exercise, the site is also expected to function as a blog where we publish weekly devlogs related to this semester\u0026rsquo;s portfolio project.\nThe portfolio is intended to act as a platform where I can present my work, document ongoing projects, and share technical reflections. While a significant part of the content will be related to my studies, the site is not limited to a single project or subject area.\nThe site itself is mine. Beyond the requirement to publish weekly devlogs as part of the programme, there are no creative or content-related constraints. That freedom allows me to treat this portfolio as something more than an assignment — as a space I can build on over time.\nMy expectation is that this will become a place where I can express myself through code and technical understanding, and use it as a portfolio that grows alongside me rather than being tied to a single semester.\nWith that out of the way, it is time to move on to the subject of this post: my semester project.\nChoosing a Project # This semester, we are part of a new exam format and, in a sense, the first test subjects for it. Rather than preparing for isolated exams, we work on a single portfolio project throughout the semester, which later forms the basis for separate backend and frontend exams.\nNew technologies are introduced gradually and applied directly in the project, making it a continuous learning process rather than a final product-driven one.\nDuring the first week of the semester, we were asked to come up with ideas for this project. That turned out to be more difficult than I expected. I initially had one idea that made sense to me, but I quickly realized that I was starting to fixate on it without properly exploring alternatives.\nAfter a fair amount of thinking — and discarding ideas that felt more like placeholders than real options — I ended up with three concrete project proposals.\nSPLITit # An expense-splitting application where users can create groups, register shared expenses, and automatically calculate who owes whom.\nSheet Herder # A Tabletop Roleplaying Game (TTRPG) application where game masters can create campaigns, and players can manage characters and notes across sessions.\nWhat\u0026rsquo;s Left # A household application where users can create a home, organize tasks across rooms, and share notes and responsibilities within a family.\nAfter presenting the three ideas to my study group and teacher, the choice ultimately fell on Sheet Herder. One of the deciding factors was its strong potential for integration with external APIs, which was something my teacher placed particular emphasis on.\nExpectations # Once the choice fell on Sheet Herder, the mental preparation began almost immediately. Choosing a single project that will shape an entire semester — and form the foundation for both exams — naturally comes with a degree of uncertainty. There is a certain fear of missing out when committing to one idea, knowing that the decision is largely irreversible.\nIt is not an easy task to select a project that will be examined through technologies I have yet to learn, or fully understand the scope of. Only time will tell whether this project can carry me through the semester, but my intuition tells me it is the right choice.\nI expect this to be a project with a relatively simple domain, but with a significant amount of predefined data and rules. That combination can quickly become overwhelming if not handled carefully. Staying within scope will therefore be essential, which places a strong emphasis on preparation and, in particular, a well-defined Minimum Viable Product (MVP).\nAt the same time, part of the appeal lies in the challenge itself. Of the three project ideas, this is the only one where I cannot yet clearly see the full solution. That uncertainty will likely become a challenge — but it is also what I expect will push my learning the most.\nCurrent Project State # The project is still in its early stages. Most of my time so far has been spent on careful analysis of the domain, which has resulted in a set of user stories, business rules, and a Definition of Done (DoD). Alongside this, I have worked on a domain model that reflects how I currently view the domain.\nAlthough the domain itself is relatively simple, arriving at a model that accurately reflects it took more time than expected. One of the key differences compared to previous projects is how roles are not directly tied to a user, but instead depend on context. A user can take on different roles depending on the campaign, which required a shift in how I approached both analysis and modeling.\nAnother deliberate focus was keeping the model lean. It was an exercise in reducing entities to their essentials and ensuring that the model reflects the actual domain, rather than drifting into TTRPG- or Dungeons \u0026amp; Dragons (D\u0026amp;D)-specific terminology such as classes, species, or similar concepts. The goal was to model what the system needs to support, not the flavor surrounding it.\nIn the next phase of the project, I will begin setting up the DAO layer. This should provide a clearer picture of how data will be structured, persisted, and accessed, and help clarify how to handle the data needed to support the application\u0026rsquo;s core functionality: the character sheet.\nClosing Thoughts # This concludes my first post.\nReading it back, it feels longer than it probably needs to be — but that is fairly typical for me. I usually strive for perfection in most things I do, which in this case resulted in me writing too much out of a nervousness about writing too little.\nAs mentioned earlier, sharing thoughts like this publicly is not something I am entirely comfortable with. The only way forward is to try things out and adjust along the way. Over time, I expect these devlogs to become more focused and precise as I settle into a format that works better for me.\nWhile this semester project is the main focus right now, it is not the only one I am interested in. The two remaining project ideas are something I plan to return to, and if they are developed further, they will be documented here as well and added to the Projects section so they can be followed in the same way.\nThank you for reading! You can follow the current project status on GitHub:\ndhangaard/sheet-herder-api Java 0 0 ","date":"6 February 2026","externalUrl":null,"permalink":"/posts/devlog-0/","section":"Posts","summary":"","title":"Sheet Herder - Initial Commit","type":"posts"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]