Work Recap 2024-03 to 2025-08 : My First Developer Job at a Clinical-Trial Startup
The Period
I started my first job as a developer in March 2024 and left in August 2025. For those eighteen months I was a backend developer at a startup building a clinical trial management solution.
This post is the long recap of that period. Along the way I wrote several posts about single problems. Two of them are folded into this one; the longer ones are linked where they belong.
What I did, in short:
- Developed the API server (Java 17, Spring Boot 3, Spring Security, JPA, QueryDSL, MySQL, Docker)
- Managed the cloud infrastructure (VPC, Subnet, Route53, ALB, Security Group, EC2, RDS, S3)
- Led the development side of the projects I was in charge of (Jira)
- Handled operational issues and their follow-up (Jira Service Management)
- Carried out CSV (Computer System Validation) to meet GMP-type regulations
Team and Process
Joining a new team
I joined a newly formed team: four data scientists including the team leader, one backend developer, and one frontend developer. I was the only backend developer, and I took over the clinical trial management service that the CTO had built.
At first I tried to earn the CTO's trust in how I pushed work forward, using what I had learned from years of working life before this job: reports tailored to what each person cared about, and design diagrams that could be read at a glance. As the company grew fast and the executives' workload grew with it, I came to be responsible for the team's development as a whole.
Managing the development process
The two developers on the team, including me, were juniors who had joined at the same time, and the team leader was a clinical expert without a development background. There was no defined process. We took requirements and built them as they came, so weak spec analysis led to rework, backend and frontend releases drifted apart and collided, and schedules were hard to manage.
I studied agile processes and introduced Jira. To keep the psychological cost low for a colleague who had no background in it, I proposed a light version and we tuned our teamwork around it.
# The process we adopted (repeated)
→ Spec : analyze requirements per feature, define the API spec
→ Epic : a release unit with a fixed schedule
→ Task : work items each developer registers
→ Impl : development, linked to issue tickets through GitHub
→ Dev : staging, then review
→ Prod : production release
This also changed the order of work. We used to go requirements → server implementation → API spec, which meant the spec kept being revised and the API kept being late. I trimmed the requirements document down to business functions, moved screen behavior into the design files, and wrote the API spec before implementing.
When a requirement was vague or had a special background, I asked for consent and talked to the client's practitioners directly: visits with the team leader, Zoom, phone, messenger. I was careful about it, as a developer speaking for the company, but it worked, and it built a network of trust with the people who actually used the service.
Cloud Infrastructure
I built and managed the team's AWS infrastructure with an administrator account: VPC, Subnet, Route53, ALB, Security Group, EC2, RDS, S3.
The servers ran as containers with Docker Compose on Ubuntu EC2 instances, and I built the CI/CD pipeline with GitHub Actions and a self-hosted runner. Migration and integration work was also mine; two of those jobs are described under Projects below.
Table Design and Entity Modeling
The main database was MySQL on RDS, with JPA as the ORM.
- Identifiers and relations. Auto-increment BIGINT primary keys; 1:N through foreign keys; N:M through a join table that also has its own BIGINT key, with a unique constraint on the pair of foreign keys.
- Column types. TIMESTAMP in UTC for every date, because the service was global. VARCHAR only for fixed-length enum-like values used in conditions; TEXT for messages and posts.
- Semi-structured data. I avoided serializing JSON into a string column, with one exception: a simple list of strings that has no business weight and would otherwise deepen the model.
- Constraints. NOT NULL only where the model demands it or the value is an enum. Because the schema changed often with requirements, validation lived in the constructors and methods of the domain entities, and we avoided touching the database around the application.
- Denormalization. Normalized by default, denormalized on purpose. In recursive, hierarchical models I stored the root parent's key next to the parent's key, so a whole tree could be read in one query and assembled in the application. I limited this to values that never change.
- Indexes. On high-cardinality columns that appear in conditions often. I avoided composite indexes unless reads suffered. For complex conditions, I first narrowed the primary keys by the most selective condition and ran a second query with
IN. - Audit. Hibernate Envers with
@Auditedon the entities that need a history, plus anAuditorAwarethat takes the current user fromSecurityContextHolder.
A related question from this time was whether a large set of codes belongs in an enum or in a code table. I worked through memory, lookup cost, naming limits, and change cost in Enum vs Code Table: Making Use of Enums.
API Design
Resource-centered URIs
// Page of subjects in project 1
GET {host}/api/v1/projects/1/subjects?page=0&size=20&sort=createdAt,desc
// Subjects named miki
GET {host}/api/v1/projects/1/subjects?name="miki"&page=0&size=20&sort=createdAt,desc
// One subject
GET {host}/api/v1/projects/1/subjects/223
// Create (the server generates the identifier)
POST {host}/api/v1/projects/1/subjects
// Idempotent update of an existing resource
PUT {host}/api/v1/projects/1/subjects/223
DELETE {host}/api/v1/projects/1/subjects/223
Resources are plural nouns, and the path shows the parent-to-child relation. Search and paging conditions go into query parameters. The server owns identifier creation, and PUT keeps updates idempotent.
One response shape, meaningful status codes
// Success, HTTP 201
{
"code": "CREATED",
"time": "2026-04-17T05:00:00Z",
"data": { "id": 223 }
}
// Error, HTTP 400
{
"code": "INVALID_INPUT_VALUE",
"time": "2026-04-17T05:00:00Z",
"data": {
"errors": [
{ "field": "name", "value": "", "message": "Validation failed for field 'name'" }
]
}
}
Every response carries code, time, and data. On an error, the business error code comes with field-level validation results when they exist, so the frontend developer can tell what went wrong without opening the API document. It is a generic DTO at the controller layer plus exception handlers in a @ControllerAdvice.
Generated OpenAPI documents
I used springdoc-openapi with @Tag, @Operation, and @Schema on controllers and DTOs, so the document follows the code. @Schema(example = "...") made the document something you could test against, and Bean Validation annotations (@NotBlank, @Min) put required values and limits into it automatically.
Tests for Maintainability
The code base I inherited had almost no tests. Requirements kept changing, and each change meant judging side effects by hand and testing endpoints with Postman. I needed a way to verify defined behavior automatically.
There was no testing culture in the company, so I learned from outside: KSUG Spring Camp and Woowa tech conference talks, and JUnit in Action. Then I started on my own service:
- For existing APIs, scenario-based end-to-end tests at the API level with RestAssured, to pin down current behavior.
- For new modules, unit tests with JUnit first.
- Slice or integration tests where complexity and testability called for them.
- Testcontainers for a production-like environment, and fixture libraries (Instancio, Datafaker).
This kept test coverage at about 50% for the existing APIs and 80% for new development. With that experience I began defining test cases first and shaping a feature's interface for testability, which is TDD in practice.
The first half of that story, with the detailed decisions about test properties, the test database, fixtures, and RestAssured, is in Introducing Acceptance Tests to a Legacy Project Without Tests.
Within a year, coding with AI agents arrived. I still think the habit of designing for testability before implementing will keep paying off in architecture.
Optimization and Monitoring in the Application
JPA
- Explicit fetch joins against N+1, and
default_batch_fetch_size: 100so lazy loading becomes a bulkINquery. - Querydsl for dynamic queries. For queries with many joins, I projected a list of entity IDs with the most selective condition and ran a second query on them.
- For aggregate and report data that has no reason to enter the persistence context, DTO projection with
@QueryProjection.
Caching
For short-lived data such as an email verification code that expires within five minutes, I kept it in memory instead of the database. Given the infrastructure and my learning curve at the time, it was a Spring bean with a ConcurrentHashMap, and a @Scheduled job that removes expired entries to prevent a leak. That was acceptable because the service was a monolith and the data was not critical. Today I would use Redis.
Asynchronous work
Calls to Adobe's API, AWS SNS, and SMS were often slow, and one request usually did several of those things at once. I separated the main business logic from the external calls with Spring events and @Async: the API answers 202 Accepted, and notifications and document processing run in the background.
So that events are not lost, each event is saved in the database inside the original transaction (Transactional Outbox). The listener updates its status after processing, and a scheduler re-publishes failed ones up to a set number of retries. An AsyncUncaughtExceptionHandler logs errors from background work at ERROR level.
AOP for authorization
As authorization grew, permission checks were leaking into business logic. I declared the required permission keys on a custom annotation and ran the check in an aspect.
@Service
@RequiredArgsConstructor
public class SubjectService {
private final SubjectRepository subjectRepository;
@CheckProjectPermission(
authorities = {PermissionKey.SUBJECT_WRITE},
projectId = "#request.projectId"
)
@Transactional
public void registerSubject(SubjectRequest request) {
subjectRepository.save(request.toEntity());
}
}
Exceptions, logging, and alerts
When I took over, an uncaught exception answered with Spring Boot's default response, stack trace included. That is handy for debugging and bad for security. I put a @RestControllerAdvice in front, returned only an error code and a minimal message, and sent the stack trace somewhere developers could see it.
- Log format unified in Logback, with log levels separated for dev and prod.
- A trace ID per request through MDC. Asynchronous work ran on Spring events, so I carried the trace ID inside the event message instead of using a
TaskDecorator. - ERROR logs pushed to Slack by webhook with the trace ID, time, exception message, and request URI.
I also watched MethodArgumentNotValidException and MethodArgumentTypeMismatchException, to catch what the client's own validation missed. The alerts helped most with asynchronous events, where an exception never reaches the client that triggered it and would otherwise stay hidden in the application.
Two rules came out of this work:
- Error codes, not server messages, for users. A server's exception message is written for developers, and it does not change with the use case. The API answers with its own error code, defined in the spec as policy, and the client turns that code into a message that fits what the user was doing.
- Exceptions are layered like the architecture. Exceptions about web requests stay near the controller and service; domain exceptions stay in the service and implementation layers; a repository or a utility never throws a client-facing exception. Otherwise the layers become coupled through their errors.
Projects
1. E-signature module on the Adobe Sign API
Signing used to mean emailing PDF files back and forth. I built a web-based signing flow inside the clinical trial monitoring solution. Two people: I designed the integration and wrote the backend, with one frontend developer.
sequenceDiagram
participant S as Clinical Trial System
participant A as Adobe Acrobat Sign API
participant U as Signer (Subject/PI)
S->>A: Authenticate, request access token
A-->>S: Access token
S->>A: Upload document (POST /transientDocuments)
A-->>S: transientDocumentId
S->>A: Create agreement (POST /agreements)
A-->>S: agreementId
A->>U: Signature request email
U->>A: Verify identity and sign
A->>S: Webhook (AGREEMENT_WORKFLOW_COMPLETED)
S->>A: Download signed PDF and audit trail
S->>S: Store document and audit log
- The server registers a webhook, so signing progress is tracked as it happens and a lookup costs only an internal database read. Reminder emails can be sent when needed.
- Adobe resends a webhook until it is acknowledged. The service acknowledges only after its own transaction commits, so signing state survives network errors and short outages and ends up consistent.
- When signing completes, the record is committed to the audit database and the signed document is kept, so the record can be checked even if the external service is down.
I read Adobe's API documents, designed the request flow, built it with a junior frontend developer, ran our own QA and staging, and released it. I reported progress to my seniors throughout and documented everything for the other developers.
2. Automatic integration of medical images from another system
Trials that use imaging as a main endpoint upload the images to a dedicated EDC system. That data was not connected to our service, so data managers uploaded files by hand. DICOM images are large and often poorly indexed, and manual upload sometimes caused omissions and duplicates. Clients asked for automatic integration. Two backend developers, one per system.
flowchart LR
subgraph EDC[External Service - EDC]
O[Original medical images]
M[Metadata storage]
end
subgraph Batch[Integration Batch]
X[Incremental data extractor] --> P[Data mapper] --> T[Thumbnail generator]
P --> E[Exception handler]
end
M --> X
P -->|Store metadata and reference| DB[(DB)]
T -->|Upload thumbnail| S3[(AWS S3)]
E -->|Push message| SL[Slack]
- A mapping layer identifies subjects, management numbers, and visit codes one-to-one across the two data structures.
- A nightly batch reads the other system's projects and migrates only the images added since the last run.
- A failure stops only the work after the failing item, and reruns are idempotent. Errors that need action go to Slack.
- The original images are not copied. Only the S3 URI from the metadata is stored, which saves storage.
- One low-volume PNG thumbnail per set is generated ahead of time, so image lists load fast.
- When a user opens an image set (usually 20 to 200 images), the service checks where each image lives: its own bucket, or the external one through a presigned URL issued after server-to-server authentication.
Medical images are grouped the DICOM way: an instance is one slice, a sequence is a set of instances taken with the same settings, and a study is everything taken for one patient in one session. With the subject on top, that is four nested 1:N levels. The two services stored this same domain with different terms and different levels, so I decided against reading the other database on every request. Our service pulls through an agreed DTO and serves from its own copy. Real-time updates were not a requirement.
Three things bothered me while building it, and I noted them in July 2024:
- The existing code assumed our own S3 bucket was the only one, in every place that handled an instance. Under time pressure I added the external path beside the old logic instead of rewriting it, which spread one piece of business logic over several places. Storing the bucket with the path would have been the cleaner model.
- Auto-increment keys are a weak fit for a platform that gathers data from several sources. With a second and third source in sight, identifiers that do not depend on one database looked better.
- The entities were bidirectional, with parents managing their children, so updating one item meant loading three levels up. That is too tightly coupled and should be simpler.
3. Medical Query module and an ABAC permission system
Issues found during a trial, such as abnormal values, must go through query → answer → response → report within a set time, and the whole process must be recorded for audit. Before, that meant capturing email threads as PDF. I designed and built a Medical Query module for international-scale trials, with one frontend developer. The requirements were shaped over repeated visits to the client's department:
- Fine-grained authorization by institution, project, and role
- Workflow control: an e-signature by the designated person and an audit trail on each status change
- Private discussion inside an institution, and alerts by urgency (email, SMS)
- UI that changes with the user's permissions
Query model. Questions and answers continue without limit, so I modeled a recursive entity. Instead of recursive SQL, each node stores the root's identifier, the group is read in one query, and the tree is rebuilt in the application.
Authorization. The service had no permission system, so I designed one based on ABAC. Permissions are split to the smallest unit and kept flat, so adding or changing one does not affect another, and they can be set per trial project against institution and project attributes. Permissions are looked up by project and user and cached briefly, and the cache is refreshed when they change. In the service code the check sits behind an interface and is applied declaratively with the annotation shown above.
I also exposed an API that returns the current user's permissions, so the frontend can render by permission and process state, and I documented every permission key and what it allows at the top of the project documents for non-developers.
4. Moving an external statistics server into the VPC without downtime
Data scientists used an R-Shiny server in a separate AWS account to compute and visualize clinical data. It sat in a public subnet with no authentication.
flowchart LR
DS[Data scientist] -->|Public key auth| BH[Bastion host]
US[User] --> FE[Frontend]
FE -->|JWT| API[Backend API - reverse proxy]
subgraph VPC[AWS VPC]
BH -->|ProxyJump| RS[R-Shiny server - private subnet]
API -->|WebSocket and streaming| RS
end
- No downtime. I moved the instance into our VPC's private subnet from a snapshot and ran the old and new servers side by side, switchable by a feature flag (a boolean in a temporary table). Changes on the old server were synchronized every minute by cron until verification was done.
- Access. Everything goes through a bastion host. I considered IAM-based access (SSM, Instance Connect), but the data scientists were used to WinSCP, so I chose public key authentication, restricted inbound traffic to company and home-office IPs, and distributed a ready-made connection profile.
- Users. The frontend used to redirect straight to the external URL. I changed it to a reverse proxy through the backend API, reusing the existing JWT authentication.
- Real time. Streaming and WebSocket are proxied too. With urgency high and fewer than 20 concurrent users, the API server took the proxy role itself.
5. Cloud storage for clinical data
The data scientists kept clinical data as CSV and XLSX files on their own computers and shared them over a messenger. When CDISC SDTM data had to enter the cloud business logic, I built central storage.
- Format. JSON, because DataFrames convert to it, on a NoSQL database (Couchbase) for indexing and searching large documents.
- Data server. A separate Spring Boot server for SDTM and unstructured data. It delegates authentication to the existing API server in the same private subnet, using the cookie on the request.
- Python library. The point was to make adoption cheap. An internal library wraps Pandas, NumPy, and Requests: it hides HTTP behind methods, converts NaN and Infinity to valid JSON, and caches credentials. A researcher keeps working with a DataFrame and calls
client.upsert(df)orclient.find(id).
6. SDTM validation API
Once data lived in the cloud, the Python validation each scientist ran locally needed to become one shared step. As a pilot, with two data scientists, I built a validation API that checks data against the standard at input and output.
I studied CDISC SDTM to get the domain right: validation runs at the Domain, Variable, and Value levels (dataset presence and identifier structure; required, expected, or permitted variables and their types; data types and controlled terminology).
The engine is in Java, built for speed:
- A Python script generates the Java enums, loaded once as singletons for fast comparison (the trade-offs are in the enum post)
- More than 10,000 string codes preloaded into immutable hash maps
- One validator per variable, found through a map from variable key to validator
- Validation chains built from atomic checks that return on the first failure and create no object on success
// One variable's validator, built once
VariableValidator aelocValidator = new ValidatorBuilder().variableName(AELOC)
.isRequired()
.isNotNull()
.isChar()
.isInCode(LOC)
.build();
// Returns an Error on the first failing check, null on success
Error error = aelocValidator.validate(target);
// Validate in parallel and collect only the errors
List<Error> errorList = validators.parallelStream()
.map(validator -> validator.validate(target))
.filter(Objects::nonNull)
.toList();
It is a synchronous API on the data server, because scientists need the result before their next step, and it is reachable from the Python library:
client = cc.Client(config)
new_data = pd.read_excel('clinical_data.xlsx')
errors = client.validate_sdtm(new_data)
# {
# KSS_0203_SUBJ_S002_AE_x1: { AELOC: ["It should be Char Type"], ... },
# KSS_0203_SUBJ_S003_AE_x2: { AELOC: ["Invalid input Code : 'UNKOWN'"], ... }
# }
For about 3 MB of JSON (20,000 to 70,000 records), validation went from around 20 seconds with a local Python script on an office PC to under 2 seconds on a t3g.large server, including the web request and response. It also caught rules the old script missed and returned a detailed report. Traffic was rare in the pilot, so I did not tune threads to the core count; ThreadPoolTaskExecutor with CompletableFuture would be the next step.
Other Notes from This Period
Two more posts came out of this work and do not belong to a single project:
- Building an Ever-Changing Mapping Map: JSON arriving from outside differed by project, and the rules for reshaping it had to change without a code change. The design is a schema holding ordered mappers, each made of conditions and functions stored as entities.
- Refactoring with Abstraction Levels in Mind: what I learned from untangling a long piece of logic after its requirements changed — keep abstraction levels consistent, split classes when there are more than two, and hide the low-level ones.
Looking Back
I came in as a junior who was the only backend developer, and that shaped everything: so each gap — no tests, no process, no monitoring, no permission system — became something to study and then build. Most of the posts linked above were written in the middle of that, and they keep the details this recap leaves out.
Some choices I would make differently now. The in-memory cache would be Redis. The API server would not double as a WebSocket proxy at larger scale. But I would keep the order of things: understand the business first, write the spec before the code, and design so that it can be tested.
Comments 0
Be the first to comment.