GeistHaus
log in · sign up

Iain Cambridge

Part of iain.rocks

The technical musings of Iain Cambridge.

stories primary
How to automate AI agents to read JIRA tickets and create pull requests
AI
During my exploration of AI and code generation I created a series of guidelines for the AI agent to follow to allow me to automate as much of the process as possible. Here I will share a view of my guideline files that I’ve created. I have them separated to allow me to be able to have the agent do stuff while not doing all of the things I usually done, normally when I’m experimenting or trying to fix something when the agent messes up and I stop it half way through.
Show full content

During my exploration of AI and code generation I created a series of guidelines for the AI agent to follow to allow me to automate as much of the process as possible. Here I will share a view of my guideline files that I’ve created. I have them separated to allow me to be able to have the agent do stuff while not doing all of the things I usually done, normally when I’m experimenting or trying to fix something when the agent messes up and I stop it half way through.

These have been tested on Junie, Claude, and Gemini. Using these files you’re able to do the follow:

Please work on ticket DEVHELM-144.

Git Usage

I wanted to be able to have my agents automatically commit stuff and create pull requests so I could code review the code using my standard approach and tooling instead of monitoring the agent and doing it in a way that I found more work.

Git Usage
=========

This document provides a quick reference for common Git commands and workflows.

# Branching and Merging

This flow must exist

Basic Flow for a feature is as follows:

* Switch to the main branch `main` then
* Pull the latest changes from the main branch with `git pull --rebase origin main`.
* Create a new branch from main for your feature with the name of the branch based on the feature you are working on.
* Make changes to the code.
* Stage the changes with `git add .` or `git add <specific files>`.
* Commit the changes with a descriptive message using `git commit -m "Your message here"`
* Push the changes to the remote repository with `git push origin <branch-name>`
* Create a pull request (PR) to merge your changes into the main branch if one doesn't exist

# Git Commit Requirements

When committing changes, ensure that:

* YOU MUST If there are PHP changes, run `vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes` to ensure the code style is correct
* The commit message MUST start with a prefix for the JIRA ticket if there is one if not DH-NIL
* The commit message is clear and descriptive.

# Pull Requests

You only create pull requests for new tasks. When handling code review for existing pull requests, you do not create new pull requests. Instead, you update the existing pull request by pushing additional commits to the same branch that the pull request was created from.

## Updating Existing Pull Requests

* YOU MUST SWTICH TO THE FEATURE BRANCH WITH `git checkout feature/DH-3-a-feature` BEFORE MAKING ANY CHANGES TO AN EXISTING PULL REQUEST.
* YOU MUST PULL THE LATEST CHANGES FROM THE FEATURE BRANCH WITH `git pull --rebase origin feature/DH-3-a-feature` BEFORE PUSHING ANY NEW COMMITS TO AN EXISTING PULL REQUEST.
* Make any necessary changes or additions to the code.
* Stage the changes with `git add .` or `git add <specific files>`.
* Commit the changes with a descriptive message using `git commit -m "Your message here"
* Push the changes to the remote repository with `git push origin <branch-name>`
* The existing pull request will automatically update with the new commits
* You will check the status of the build with a 1 minute pause inbetween checks and address any issues that arise

## Create Pull Requests

When creating a pull request, follow these guidelines:

* It should include a clear title and description of the changes made.
* It should reference the JIRA ticket if applicable.
* It should be assigned to the appropriate reviewer (that-guy-iain).
* It should be linked to the relevant JIRA ticket.
* If there is already a pull request for the same branch, you should not create a new one. Instead, update the existing pull request with your changes.
* There should only be one pull request per JIRA ticket.
* * You will check the status of the build with a 1 minute pause inbetween checks and address any issues that arise
JIRA Usage

I use JIRA a lot, especially at work, so I wanted my agents to be told to do a ticket and it’ll then fetch everything from confluence and other files.

JIRA Usage
==========

This document provides a quick reference for how we use JIRA.

You will find the identity of the lead developer making the request in the file located at `~/agent_info.txt`.

## Project

The JIRA for this project is DevHelm. The project key is DH.

## Ticket Research

When you are told to work on a ticket, you should first research the ticket to understand what it is about. This includes:

* YOU MUST Assign the ticket to the lead developer  making the request so they can keep track of it if it is not already assigned.
* Reading the ticket description.
* The label for the ticket is important; it will tell you what type of ticket it is. For example, if the label is "web" then then the work belongs to the web project. If the label is "mcp" then the work belongs to the MCP project. If the label is "agent" then the work belongs to the agent project.
* Checking the comments for any additional context or updates.
* Reviewing any attachments or linked issues for relevant information.
* Understanding the acceptance criteria and any dependencies.
* Reading the ticket history to see how it has evolved.
* Reading any related Confluence pages or documentation that may provide additional context.
* If there are scenarios/features Gherkin in the ticket description that is to be used as a feature file and pass

## New Task

* YOU MUST MOVE THE TICKET TO "IN PROGRESS".
* YOU MUST COMMENT WHEN YOU'VE FINISHED WORKING ON THE TICKET IN THE COMMENTS SECTION OF THE TICKET WITH A LINK TO THE PULL REQUEST AND MOVE THE TICKET TO "IN REVIEW".


## Code Review

* YOU MUST COMMENT WHEN YOU'VE FINISHED WORKING ON THE TICKET IN THE COMMENT SECTION OF THE TICKET WITH A LINK TO THE PULL REQUEST AND MOVE THE TICKET TO "IN REVIEW"
DevHelm

Here are the DevHelm guidelines, for a Symfony application. This ensures it follows the Symfony standards I want. But more importantly it’ll create a feature file based on any Gerkhin is found within the JIRA ticket.

TL;NR - You can get it at (https://raw.githubusercontent.com/DevHelm/DevHelm/refs/heads/main/.junie/web/guidelines.md([https://raw.githubusercontent.com/DevHelm/DevHelm/refs/heads/main/.junie/web/guidelines.md])

# DevHelm Web Development Guidelines

This document provides essential information for developing the ComControl web application, a Symfony 7.2 + Vue.js 3 project with comprehensive testing setup.

## Project Architecture

The web application is built using:
- **Backend**: Symfony 7.2 with PHP 8.2+
- **Frontend**: Vue.js 3 with Webpack Encore
- **Styling**: Tailwind CSS with PurgeCSS optimization
- **Build System**: Webpack Encore with Babel for transpilation

## Build and Configuration

### Prerequisites
- PHP 8.2 or higher
- Node.js and npm
- Composer

### Initial Setup
\`\`\`bash
# Install PHP dependencies
composer install

# Install JavaScript dependencies
npm install

# Copy configuration files (if needed)
cp .env.example .env
cp phpunit.dist.xml phpunit.xml
cp behat.yml.dist behat.yml
\`\`\`

### Build Commands

#### Development
\`\`\`bash
# Start the docker environment
docker compose up -d 

# Build assets for development
npm run dev

# Build assets and watch for changes
npm run watch

# Start development server with HMR
npm run dev-server
\`\`\`

#### Production
\`\`\`bash
# Build optimized assets for production
npm run build
\`\`\`

### Webpack Configuration

The `webpack.config.js` uses Symfony Encore with:
- Vue.js loader enabled
- Sass/SCSS support
- Tailwind CSS with PurgeCSS for production optimization
- Source maps in development
- Asset versioning in production

**Important Note**: There's a syntax error in `webpack.config.js` line 33: `pluginsplugins` should be `plugins`.

### Asset Structure
- Entry point: `./assets/app.js`
- Output directory: `public/build/`
- Templates scanned for PurgeCSS: `./templates/**/*.twig`, `./assets/js/**/*.vue`, `./assets/js/**/*.js`

## Testing Framework

The project uses multiple testing frameworks for comprehensive coverage:

### JavaScript Testing (Jest)

#### Configuration
- Config file: `jest.config.js` (minimal configuration with v8 coverage provider)
- Test files location: `assets/services/__tests__/`
- Pattern: `*.test.js` or `*.spec.js`

#### Running JavaScript Tests
\`\`\`bash
# Run all JavaScript tests
npm test

# Run specific test file
npm test -- assets/services/__tests__/example.test.js

# Run with coverage
npm test -- --coverage
\`\`\`

#### Example Test Structure
\`\`\`javascript
describe('Test Suite Name', () => {
    test('test description', () => {
        expect(actual).toBe(expected);
    });
});
\`\`\`

**Important**: Existing tests use Vitest imports but Jest is configured as the test runner. Use Jest syntax for new tests.

### PHP Unit Testing (PHPUnit)

#### Configuration
- Config file: `phpunit.dist.xml`
- Test directory: `tests/`
- Bootstrap: `tests/bootstrap.php`
- Environment: `APP_ENV=test`

#### Running PHP Tests
\`\`\`bash
# Start the docker environment
docker compose up -d 

# Run all unit tests
vendor/bin/phpunit

# Run specific test file
vendor/bin/phpunit tests/Unit/ExampleTest.php

# Run tests with coverage
vendor/bin/phpunit --coverage-html coverage/
\`\`\`

#### Test Structure
\`\`\`php
<?php

namespace App\Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function testExample(): void
    {
        $this->assertTrue(true);
        $this->assertEquals(expected, actual);
    }
}
\`\`\`

### BDD Testing (Behat)

#### Configuration
- Config file: `behat.yml.dist`
- Features directory: `features/`
- Context classes in: `App\Tests\Behat\`

#### Available Contexts
- `DemoContext`
- `GeneralContext` 
- `UserContext`
- `PlanContext`
- `TeamContext`

#### Running Behat Tests

\`\`\`bash
# Start up docker
docker compose up -d

# Run all BDD tests
docker compose exec php-fpm vendor/bin/behat

# Dry run to check syntax
docker compose exec php-fpm vendor/bin/behat --dry-run

# Run specific feature
docker compose exec php-fpm vendor/bin/behat features/demo.feature
\`\`\`

## Code Quality

### PHP CS Fixer
The project uses PHP CS Fixer for code style enforcement:

\`\`\`bash
# Fix code style (as per git guidelines)
docker compose exec php-fpm vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes
\`\`\`

### PHP Code Standards

* **Use Attributes Over Annotations**: Always prefer PHP 8 attributes over docblock annotations:
    - ✅ DO: `#[Route('/api/users', name: 'api_users')]`
    - ❌ DON'T: `@Route("/api/users", name="api_users")`
    - ✅ DO: `#[Assert\NotBlank]`
    - ❌ DON'T: `@Assert\NotBlank`

  Attributes are natively supported in PHP 8+, provide better type safety, better IDE support, and are part of the language syntax rather than comments.

### Testing Guidelines

1. **JavaScript Tests**: Place in `assets/services/__tests__/` with `.test.js` extension
2. **PHP Unit Tests**: Place in `tests/Unit/` with `Test.php` suffix
3. **Integration Tests**: Use `tests/Integration/` directory
4. **BDD Tests**: Create `.feature` files in `features/` directory
5. **Do Not Test Logic-less Classes**: Do not write tests for entities, DTOs, and other classes that contain no logic. These classes typically only define properties and getters/setters without business logic, making tests redundant and maintenance-heavy. Focus testing efforts on classes that contain actual business logic.

## Architecture
### Value Objects and Enums

* Value Objects and Enums are held within the namespace that they belong to within the purposes of the domain. For example, a Money Value Object would be in the App\Entity namespace as it is used by entities. And an enum representing the status of a Subscription would be in the DevHelm\Subscription namespace.

### Repositories

* The repository pattern that is used throughout this project is documented in repository-pattern.md
* DOCTRINE MUST NOT BE USED OUTSIDE OF THE `App\Repository` NAMESPACE
* When querying entities that only need data from a single entity, use the `findOneBy` and `findBy` methods instead of QueryBuilder. QueryBuilder should only be used for complex queries involving multiple tables/entities.

### DTOs

* The DTOS are held within the App\Dto namespace. They are readonly classes that use the constructor promotion and only contain public members.
* DTOs are organised by endpoint type api, app, and webhook which is decided based on the route of the controller action. And then organised further into Request and Response based upon if they are used to represent the request body or response body. Webhooks may not have a DTO for all endpoints, but API and APP MUST have DTOs for their Request and Response.
* DTOs are to use the Symfony Serializer component. And members are to be snake_case and not camelCase.
* Response DTOs are to be created within the Factory relating to that domain item.
* And Generic will be things such as ListResponse.

Structure:

|- Generic
|- Api
|   | - Request
|   | - Response
|- App
|   | - Request
|   | - Response


### Controllers

* Controllers are organised by endpoint type api, app, and webhook, which is decided based on the route of the controller action.
* Controllers *MUST NOT* use doctrine EntityManager directly and MUST use a repository interface. Dependencies should be injected into the action and not the constructor.
* Controllers are to use the Symfony Serializer component to deserialize request bodies into DTOs and serialize response DTOs into JSON.
* Controllers are to use the Symfony Validator component to validate request DTOs.
* Controllers are to use the Parthenon LoggerTrait for logging.
* Controllers should have dependencies injected into the action method rather than the constructor.
* Controllers MUST log the receipt of requests and key actions taken, including any errors encountered.
* Controllers MUST not be unit tested but tested via functional tests or Behat.

#### File and Class Organization Requirements

* **Single Class Per File**: PHP files MUST contain only a single class. Multiple classes in one file are not permitted:
    - ✅ DO: One controller class per file
    - ❌ DON'T: Multiple controller classes in the same file

* **Controller Action Grouping**: All controller actions for a single entity/domain MUST be grouped into a single controller class:
    - ✅ DO: All agent-related actions (create, list, update, delete) in `AgentController`
    - ❌ DON'T: Separate controller classes for different routes of the same entity (e.g., `AgentController` and `AgentSingleController`)

#### Controller-Specific Logging and User Injection Guidelines

* **Logger Usage with LoggerAwareTrait**: When using `LoggerAwareTrait`, do NOT inject `LoggerInterface` into controller actions. Instead, access the logger via `$this->getLogger()`:
    - ✅ DO: `$this->getLogger()->info('Message');`
    - ❌ DON'T: Inject `LoggerInterface $logger` and use `$this->setLogger($logger);`

* **User Injection**: Always inject the current user using the `#[CurrentUser]` attribute, not by manually retrieving from request attributes:
    - ✅ DO: `#[CurrentUser] User $user` in action parameter
    - ❌ DON'T: `$user = $request->attributes->get('_user');`

* **Team Access**: Do not add unnecessary sanity checks for user team relationships. If the user is authenticated and authorized, assume valid team relationships exist:
    - ✅ DO: `$team = $user->getTeam();` (direct access)
    - ❌ DON'T: `if (!$team instanceof Team) { return new JsonResponse(['error' => 'User must belong to a team'], Response::HTTP_FORBIDDEN); }`

**Structure:**

|- Api
|- App
|- Webhooks


### CRUD Actions

**Create:**
\`\`\`php
    #[IsGranted('ROLE_LEAD')]
    #[Route('/app/product/{id}/price', name: 'app_product_price_create', methods: ['POST'])]
    public function createPrice(
        Request $request,
        SerializerInterface $serializer,
        ValidatorInterface $validator,
        PriceRepositoryInterface $priceRepository,
        ProductRepositoryInterface $productRepository,
        PriceFactory $priceFactory,
    ) {
        $this->getLogger()->info('Received request to create price', ['product_id' => $request->get('id')]);

        try {
            /** @var Product $product */
            $product = $productRepository->getById($request->get('id'));
        } catch (NoEntityFoundException $e) {
            return new JsonResponse([], JsonResponse::HTTP_NOT_FOUND);
        }

        /** @var CreatePrice $dto */
        $dto = $serializer->deserialize($request->getContent(), CreatePrice::class, 'json');
        $errors = $validator->validate($dto);

        if (count($errors) > 0) {
            $errorOutput = [];
            foreach ($errors as $error) {
                $propertyPath = $error->getPropertyPath();
                $errorOutput[$propertyPath] = $error->getMessage();
            }

            return new JsonResponse([
                'errors' => $errorOutput,
            ], JsonResponse::HTTP_BAD_REQUEST);
        }

        $price = $priceFactory->createPriceFromDto($dto);
        $price->setProduct($product);

        $priceRepository->save($price);
        $dto = $priceFactory->createAppDto($price);
        $jsonResponse = $serializer->serialize($dto, 'json');

        return new JsonResponse($jsonResponse, JsonResponse::HTTP_CREATED, json: true);
    }
\`\`\`

**List:**

All CRUD list pages MUST follow this pattern using the CrudRepositoryInterface getList method with proper pagination and filtering:

\`\`\`php
    #[Route('/app/price', name: 'app_price_list', methods: ['GET'])]
    public function listPrices(
        Request $request,
        PriceRepositoryInterface $priceRepository,
        SerializerInterface $serializer,
        PriceDataMapper $priceFactory,
    ): Response {
        $this->getLogger()->info('Received request to list prices');

        $lastKey = $request->get('last_key');
        $firstKey = $request->get('first_key');
        $resultsPerPage = (int) $request->get('limit', 10);

        if ($resultsPerPage < 1) {
            return new JsonResponse([
                'reason' => 'limit is below 1',
            ], JsonResponse::HTTP_BAD_REQUEST);
        }

        if ($resultsPerPage > 100) {
            return new JsonResponse([
                'reason' => 'limit is above 100',
            ], JsonResponse::HTTP_REQUEST_ENTITY_TOO_LARGE);
        }

        // Add filters based on business logic (e.g., team filtering, user access, etc.)
        $filters = [
            // Example: 'team' => $user->getTeam()->getId(),
        ];

        $resultSet = $priceRepository->getList(
            filters: $filters,
            limit: $resultsPerPage,
            lastId: $lastKey,
            firstId: $firstKey,
            sortKey: 'id', // or relevant sorting field
            sortType: 'DESC',
        );

        $dtos = array_map([$priceFactory, 'createAppDto'], $resultSet->getResults());

        $listResponse = new ListResponse();
        $listResponse->setHasMore($resultSet->hasMore());
        $listResponse->setData($dtos);
        $listResponse->setLastKey($resultSet->getLastKey());

        $json = $serializer->serialize($listResponse, 'json');

        return new JsonResponse($json, json: true);
    }
\`\`\`

**Key Requirements for CRUD List Pattern:**
- MUST use CrudRepositoryInterface getList method with all parameters
- MUST include pagination validation (limit 1-100)
- MUST include proper filtering based on user context
- MUST use firstId, lastId, sortKey, and sortType parameters
- MUST return proper ListResponse with hasMore and lastKey

**Update:**

\`\`\`php
    #[IsGranted('ROLE_LEAD')]
    #[Route('/app/product/{id}/price/{priceId}/delete', name: 'app_product_price_delete', methods: ['POST'])]
    public function deletePrice(
        Request $request,
        PriceRepositoryInterface $priceRepository,
    ) {
        $this->getLogger()->info('Received request to delete price', ['product_id' => $request->get('id'), 'price_id' => $request->get('priceId')]);

        try {
            /** @var Price $price */
            $price = $priceRepository->findById($request->get('priceId'));
        } catch (NoEntityFoundException $exception) {
            return new JsonResponse([], JsonResponse::HTTP_NOT_FOUND);
        }

        $price->markAsDeleted();
        $priceRepository->save($price);

        return new JsonResponse([], JsonResponse::HTTP_ACCEPTED);
    }
\`\`\`

**Delete**
\`\`\`php
    #[IsGranted('ROLE_LEAD')]
    #[Route('/app/product/{id}/price/{priceId}/delete', name: 'app_product_price_delete', methods: ['POST'])]
    public function deletePrice(
        Request $request,
        PriceRepositoryInterface $priceRepository,
    ) {
        $this->getLogger()->info('Received request to delete price', ['product_id' => $request->get('id'), 'price_id' => $request->get('priceId')]);

        try {
            /** @var Price $price */
            $price = $priceRepository->findById($request->get('priceId'));
        } catch (NoEntityFoundException $exception) {
            return new JsonResponse([], JsonResponse::HTTP_NOT_FOUND);
        }

        $price->markAsDeleted();
        $priceRepository->save($price);

        return new JsonResponse([], JsonResponse::HTTP_ACCEPTED);
    }
\`\`\`

**Edit:**

\`\`\`php
 #[IsGranted('ROLE_LEAD')]
    #[Route('/app/product/{id}/update', name: 'app_product_update_view', methods: ['GET'])]
    public function viewUpdateProduct(
        Request $request,
        ProductRepositoryInterface $productRepository,
        ProductDataMapper $dataMapper,
        TaxTypeRepositoryInterface $taxTypeRepository,
        TaxTypeDataMapper $taxTypeDataMapper,
        SerializerInterface $serializer,
    ): Response {
        $this->getLogger()->info('Received request to read update products', ['product_id' => $request->get('id')]);

        try {
            $product = $productRepository->getById($request->get('id'));
        } catch (NoEntityFoundException $exception) {
            return new JsonResponse(['success' => false], JsonResponse::HTTP_NOT_FOUND);
        }

        $taxTypes = $taxTypeRepository->getAll();
        $taxTypeDtos = array_map([$taxTypeDataMapper, 'createAppDto'], $taxTypes);
        $view = new UpdateProductView();
        $view->setProduct($dataMapper->createAppDtoFromProduct($product));
        $view->setTaxTypes($taxTypeDtos);

        $json = $serializer->serialize($view, 'json');

        return new JsonResponse($json, json: true);
    }

    #[IsGranted('ROLE_LEAD')]
    #[Route('/app/product/{id}', name: 'app_product_update', methods: ['POST'])]
    public function updateProduct(
        Request $request,
        ProductRepositoryInterface $productRepository,
        SerializerInterface $serializer,
        ValidatorInterface $validator,
        ProductDataMapper $productFactory,
    ): Response {
        $this->getLogger()->info('Received request to write update products', ['product_id' => $request->get('id')]);

        try {
            /** @var Product $product */
            $product = $productRepository->getById($request->get('id'));
        } catch (NoEntityFoundException $e) {
            return new JsonResponse([], JsonResponse::HTTP_NOT_FOUND);
        }

        /** @var CreateProduct $dto */
        $dto = $serializer->deserialize($request->getContent(), CreateProduct::class, 'json');
        $errors = $validator->validate($dto);

        if (count($errors) > 0) {
            $errorOutput = [];
            foreach ($errors as $error) {
                $propertyPath = $error->getPropertyPath();
                $errorOutput[$propertyPath] = $error->getMessage();
            }

            return new JsonResponse([
                'errors' => $errorOutput,
            ], JsonResponse::HTTP_BAD_REQUEST);
        }

        $newProduct = $productFactory->createFromAppCreate($dto, $product);

        $productRepository->save($newProduct);
        $dto = $productFactory->createAppDtoFromProduct($newProduct);
        $jsonResponse = $serializer->serialize($dto, 'json');

        return new JsonResponse($jsonResponse, JsonResponse::HTTP_ACCEPTED, json: true);
    }
\`\`\`

### Frontend

* Within the Frontend, submit buttons should use the Parthenon SubmitButton component.
* When loading pages or changing views, it should use LoadingScreen component.
* THERE SHOULD NEVER BE RAW STRINGS IN THE TEMPLATE. EVERYTHING *MUST* BE A LOCALISATION ID
* Translations should be in British English, American English, and German
* CSS should use tailwind utils

## Comments

* All classes and methods should only have doc blocks if not type hinted.
* Inline comments should only be used for very complex logic. Almost never.

## Committing

* To ensure that the code style is correct YOU MUST run `web/vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes` before committing any PHP changes.

## Development Workflow

### Creating New Tests

#### JavaScript Test Example
\`\`\`javascript
// assets/services/__tests__/myservice.test.js
describe('MyService', () => {
    test('should perform expected operation', () => {
        // Test implementation
        expect(result).toBe(expected);
    });
});
\`\`\`

#### PHP Test Example
\`\`\`php
<?php
// tests/Unit/MyServiceTest.php

namespace App\Tests\Unit;

use PHPUnit\Framework\TestCase;

class MyServiceTest extends TestCase
{
    public function testShouldPerformExpectedOperation(): void
    {
        // Test implementation
        $this->assertEquals($expected, $actual);
    }
}
\`\`\`

### Testing with Enums, DTOs, and Readonly Classes

1. **Enums**: When testing with PHP enums, always use the actual enum cases directly instead of mocking them:
    - ✅ DO: `$agent->method('getStatus')->willReturn(AgentStatus::Enabled);`
    - ❌ DON'T:
      ```php
      $status = $this->createMock(AgentStatus::class);
      $status->value = 'enabled';
      $agent->method('getStatus')->willReturn($status);
      ```

   When making assertions involving enums, always compare against the enum case directly rather than its string or numeric value:
    - ✅ DO: `$this->assertEquals(AgentStatus::Enabled, $agent->getStatus());`
    - ✅ DO: `$this->assertSame(AgentStatus::Enabled, $agent->getStatus());`
    - ❌ DON'T: `$this->assertEquals('enabled', $agent->getStatus()->value);`
   - ❌ DON'T: `$this->assertEquals(1, $agent->getStatus()->value);`

2. **DTOs**: When testing with DTOs (Data Transfer Objects), use the actual DTO classes rather than mocks:
    - ✅ DO: `$dto = new SomeResponseDto('value1', 'value2');`
    - ❌ DON'T: `$dto = $this->createMock(SomeResponseDto::class);`

3. **Readonly Classes**: Similar to DTOs, readonly classes should be instantiated directly in tests, not mocked:
    - ✅ DO: `$valueObject = new SomeValueObject('value1', 'value2');`
    - ❌ DON'T: `$valueObject = $this->createMock(SomeValueObject::class);`

Using real objects instead of mocks for these types provides several benefits:
- Tests more closely match real application behavior
- Eliminates subtle bugs caused by incomplete mocking
- Improves readability and maintainability of test code
- Reduces test fragility when refactoring these objects

Exception: Only mock these objects when absolutely necessary for specific test isolation requirements, and document the reason in a comment.


### Common PHPUnit Assertions
- `$this->assertTrue($condition)`
- `$this->assertEquals($expected, $actual)`
- `$this->assertStringContainsString($needle, $haystack)`
- `$this->assertCount($expectedCount, $array)`
- `$this->assertInstanceOf($expected, $actual)`

### Common Jest Matchers
- `expect(actual).toBe(expected)`
- `expect(actual).toEqual(expected)`
- `expect(string).toContain(substring)`
- `expect(array).toHaveLength(number)`
- `expect(promise).resolves.toBe(expected)`

## Key Dependencies

### Backend (PHP)
- Symfony 7.2 (Framework Bundle, Console, Mailer)
- Doctrine ORM with Migrations
- Parthenon (SaaS framework)
- JIRA Cloud REST API integration
- Monolog for logging

### Frontend (JavaScript)
- Vue.js 3 with Vue Router and Vuex
- Tailwind CSS with forms plugin
- FontAwesome icons
- Axios for HTTP requests
- Vue Stripe integration

### Development Tools
- Webpack Encore for asset compilation
- Babel for JavaScript transpilation
- Sass/SCSS support
- Jest for JavaScript testing
- PHPUnit for PHP testing
- Behat for BDD testing

## Debugging

### Symfony Profiler
Available in development mode at `/_profiler` after making requests.

### Log Files
- Application logs: `var/log/dev.log`
- Test logs: Check test environment logs

### Asset Issues
- Clear Webpack cache: `rm -rf node_modules/.cache`
- Rebuild assets: `npm run dev`
- Check for syntax errors in `webpack.config.js`

## Environment Configuration

### Environment Files
- `.env`: Main environment configuration
- `.env.local`: Local overrides (not committed)
- `.env.test`: Test environment settings

### Important Environment Variables
- `APP_ENV`: Application environment (dev/prod/test)
- `DATABASE_URL`: Database connection string
- `JIRA_*`: JIRA integration settings

---
### General Code Practices with Enums

1. **Enum Comparisons in Source Code**: When comparing or asserting enum values in source code (not just tests), always compare against the enum case directly:
    - ✅ DO: `if ($status === AgentStatus::Enabled) { ... }`
    - ❌ DON'T: `if ($status->value === 'enabled') { ... }`
    - ✅ DO: `return $status === AgentStatus::Disabled;`
    - ❌ DON'T: `return $status->value === 0;`

2. **Using Enums in Match Expressions**: Prefer using match expressions with enum cases:
    - ✅ DO:
      \`\`\`php
      $result = match($status) {
          AgentStatus::Enabled => 'active',
          AgentStatus::Disabled => 'inactive',
          default => 'unknown'
      };
      \`\`\`
    - ❌ DON'T:
      \`\`\`php
      $result = match($status->value) {
          'enabled' => 'active',
          'disabled' => 'inactive',
          default => 'unknown'
      };
      \`\`\`

Using enum cases directly rather than their values provides type safety, better refactoring support, and clearer code intent. It also prevents issues if the string or numeric representation of an enum changes.

This document outlines the development guidelines specific to the control application component of the DevHelm project.

## Code Organization

### Namespace Guidelines

Namespaces must describe the domain aspect of the code, not just technical implementation details. This ensures better organization and maintainability.

**Good examples:**
- `DevHelm\Control\Ticket\*` - for ticket/task related functionality
- `DevHelm\Control\Agent\*` - for agent-related functionality
- `DevHelm\Control\User\*` - for user-related functionality
- `DevHelm\Control\Security\*` - for security-related functionality

**Avoid generic namespaces:**
- `DevHelm\Control\Interfaces\*` - too generic, doesn't indicate domain
- `DevHelm\Control\Services\*` - too generic, classes should be grouped by domain
- `DevHelm\Control\ValueObjects\*` - too generic, classes should be grouped by domain
- `DevHelm\Control\Helpers\*` - too generic, doesn't indicate purpose
- `DevHelm\Control\Utils\*` - too generic, doesn't indicate domain

**Domain-based grouping rule:**
All classes should be grouped by their domain aspect, not by technical implementation pattern. For example:
- `ApiKeyGenerator` belongs in `Security\*` (domain: security/authentication)
- `JiraProvider` belongs in `Ticket\*` (domain: ticket management)
- `Ticket` value object belongs in `Ticket\*` (domain: ticket management)
- `UserManager` belongs in `User\*` (domain: user management)

**Namespace structure conventions:**
- Use domain-specific names for interfaces: `Ticket\ProviderInterface` instead of `Interfaces\TicketProviderInterface`
- Group all functionality by domain first, then by type if needed within the domain
- Avoid ALL technical implementation namespaces like `Services`, `Managers`, `Handlers`, `ValueObjects`, `Entities`
- Every class, regardless of implementation pattern (service, value object, entity, etc.), should be grouped by its business domain

### Factory Guidelines

Factories should be merged when they serve the same domain concept. For example, `AgentFactory` should handle both entity creation and DTO creation for agents rather than having separate factories.

### Exception Handling

- Symfony interfaces should throw Symfony exceptions
- DevHelm domain code should throw DevHelm exceptions when appropriate
- Do not create custom exceptions unless there's a clear domain-specific need

*Last updated: 2025-08-27*
https://iain.rocks/blog/how-to-automate-ai-agents-to-read-jira-tickets-and-create-pull-requests
Design Decision: Technical Debt in BillaBear
Code ReviewDesign DecisionSoftware Design
Like any startup code there has to be decisions made about where you’re going to spend time working and what you’re going to leave for latter when you have the time and money to resolve the issues you choose to ignore. Here I’ll list what the current problems are to prepare for when I’m going to start resolving the issues.
Show full content

Like any startup code there has to be decisions made about where you’re going to spend time working and what you’re going to leave for latter when you have the time and money to resolve the issues you choose to ignore. Here I’ll list what the current problems are to prepare for when I’m going to start resolving the issues.

Out of Date Practices

There are some things I did because I did them previously and I just needed something quickly. Also, for somethings I was supporting an old version of PHP.

Data Transfer Objects

One of the issues within the codebase that I’m actively working on improving and dealing with but will take some time is changing over the classes from the old standard method of defining properties within the body of the class to moving over to constructor promotion and readonly classes. This was originally done because it was super easy to copy stuff over from other classes and modify. It was also what I was used to since at the time 7.x was still in wide usage in production systems that I was used to working with.

Enums

With Enums, I was lazy and was just googling how to do things and I found some code somewhere, I’m not 100% sure where tho, and I went with that style. So I needed to remove that code and then I realised that I put them in a single namespace to keep them together, this didn’t really fit in with the DDD architecture (not hexangonal architecture). This meant I felt like I should and kinda had to my opinion move them to be within the correct namespace to keep the correct architecture for the codebase.

Refactored but not completed

There are somethings I did like creating my old background processing system before Symfony had the Symfony Scheduler.

There are things I did because I wanted something to do it but I wasn’t 100% sure on how to do it and later I’ve found a better way or a feature has been added to Symfony.

Serialization

One of the features that got added and basically improved was the ability to define what DTOs are to be used and to do the serialisation before it gets to the controller. I’m not really 100% sure I want to use this new approach because I’m a bit worried I’ll lose control of the error handling. Which is a minor issue, but I really want to have diagnostic logging in my error handling, and for that error logging to allow me to pinpoint which controller and method was being used. Which I can find out via other logging methods, but I’m lazy, and it’s easier to write logging code once and then look for the log messages than continuously look through all the logs and figure out what the request was actually for.

DataMappers

I was never really a fan of the name; it felt like they should be called factories. While the overall way the datamappers are built is good, Symfony has actually added ObjectMappers. So now I want to refactor to use them because everyone will want to use the framework’s provided functionality. The benefit of this is it basically removes the need for a separate class, as we’re now able to just add attributes to the DTOs and say what we want them mapped to.

Things I don’t really like and are improvable

Here are some of the things I don’t really like and will be improving in the future because I think they’re kinda eww.

Elasticsearch

I added some elasticsearch code but ended up not using it, but the way I was using it wasn’t that good since I didn’t wrap the client properly. However, it turns out that I don’t think that Elasticsearch is actually what I want or need to be using. Since it’s a full text search powered by lucene and I’m doing UUID searches, it makes more sense to use a database that handles uuids and indexes on those correctly in a performant manner. It’s extremely likely I’ll encounter a problem that elasticseach solves well and will use that then, but for now it’s overkill and not the best match. The exact use case just now is to be able to fetch the audit logs for users, subscriptions, and admin users, which is just searching for that id and then adding pagination.

Namespaces

One of the things I did that I didn’t like and still needs to be refactored, something I like more, was the namespaces that are currently.

An example of one of the namespaces I don’t like is I called the customer portal public which doesn’t really make much sense. So it’ll get renamed to portal. I’m just waiting until I’ve cleaned up other, more important, and much larger messes.

Unit Test

One of the few things people criticise BillaBear is the lack of unit testing. While they do have a point, in that there are very few (they often claim none), I made the decision to focus on functional since I wanted to have BDD feature files. There are some unit tests but not as many compared to the functional behat tests, so few that many people wrongly assert that there are no unit tests.

This is something that will be improved over time. Especially since AI is pretty good at writing unit tests for pre-existing code and will only get better, for those curious about AI and want to test it out, that is definetly the first place to look and play to see the quality it can do and how to improve your guidelines to have it produce high-quality tests first time round.

Frontend

The frontend code is an absolute mess. I’m a hardcore backend developer so learning frontend wasn’t high on my todo list. I got a basic setup working and had the UI looking ok and usable. Over time I gradulally got better at frontend code and started to add better frontend code that followed the best practices more. Things such as composing UI elements and using stores correctly to allow for the change of state between components.

There is a lot of work, and honestly, I’m thinking of hiring a project recovery team to fix and improve the code since it’s a lot of work and I want to focus on the backend and improve that.

https://iain.rocks/blog/technical-debt-in-billabear
Rejecting rebase and stacked diffs, my way of doing atomic commits
GitDebugging
I recently learnt about atomic commits after spotting an unusual note on a pull request someone asked me to review. While I’m a bit embarrassed it took me so long to even hear about them, once someone explained atomic commits to me—and showed me the power of bisect when you’re using them, plus how much flexibility they give you with Git, was sold.
Show full content

I recently learnt about atomic commits after spotting an unusual note on a pull request someone asked me to review. While I’m a bit embarrassed it took me so long to even hear about them, once someone explained atomic commits to me—and showed me the power of bisect when you’re using them, plus how much flexibility they give you with Git, was sold.

Quick Rundown Of Atomic Commits

To be fair, you should read “Atomic commits will help you git legit. with this git cheatsheet” which I skimmed to get a grasp of. The concept of single units is clear for most of us. But the benefits of doing it on a commit level were never obvious to me, and that post points out those benefits pretty clearly.

For those who want a super simple breakdown: atomic commits are when you only commit a single logical unit of code that can pass the CI build. In other words, each commit should represent one cohesive change or feature that makes sense on its own. This means that if someone were to review your commit history, they could easily understand what each commit does without needing to look at multiple commits together. The key principle here is that every commit should leave your codebase in a working state - nothing should be broken, all tests should pass, and the code should compile successfully. Think of it like saving your progress in a video game at checkpoints that actually make sense, rather than saving randomly in the middle of a boss fight.

The benefits include:

  • Easier code reviews since you can review each commit independently
  • Better ability to use bisect since every commit passes tests
  • The option to do pure CI trunk-based development, where everyone pushes directly to the main branch
The Problem

I currently develop using a pattern where I start with a functional test that I make fail. Then I drill into the entry point in the application and build units of code, doing TDD on those units until I eventually make the functional test pass. This means my working branch always has code that doesn’t belong to the logical unit of code that should be contained within atomic commits.

So naturally I asked what atomic commits are actually doing, since this probably wasn’t just a me problem. Turns out there are various approaches. One is Stacked Diffs, which I think is overkill for when you’re just working on a single reviewable feature which needs multiple commits. Another is reordering commits and pushing for WIP, which sounds like a lot of work. And then there were some truly wild options.

I think the other solutions are fundamentally workarounds, and here’s why. They both work off the concept that there is going to be code that is not going to pass the build and that we will intentionally splinter off from that broken state. Either we specially craft our PRs in a very particular way to hide the broken commits, or we use special code review tools and processes to work around the fact that we’re not being pure in our actions and intentions. Meanwhile, atomic commits are all about being pure from the start – every single commit should be complete, functional, and able to stand on its own. With atomic commits, each commit represents a complete, working change that builds successfully and passes all tests. There’s no broken state to hide or work around because every step of the way, the codebase remains in a valid, deployable state.

The Solution

For me, the fundamental problem is that we’re using git wrong. Git has the features we need—if we use them correctly, this issue goes away completely. We’re relying on one feature to do everything when we should be using two distinct features that were each designed for specific purposes. Right now, we’re using commits for both storing WIP code and tracking code we actually want to preserve in our project history. This conflates two very different use cases and creates confusion about what our commit history actually represents.

Instead, we should use the stash functionality that git provides specifically for temporary work. Stash was designed to store files while we switch branches, and that’s the only time you really need your WIP code set aside temporarily. It’s a perfect fit for this scenario. So we’d stage and commit only the ready code—code that’s been tested, reviewed, and is genuinely ready to become part of the permanent project history. We’d push those meaningful commits to share them with the team. And we’d stash our work-in-progress code whenever we need to switch contexts or jump to a different branch to handle an urgent bug fix or review someone else’s code.

If we do it correctly by hand, that’s a lot of git commands and tons of work. So realistically, no one with a brain is going to do it. Which is why no one is doing it and are doing the other approaches. I would rather just automate the tricky things and try and blend it in as easy as possible to my workflow. I find if people need to go out of their way too much they will just refuse to do it despite the benefits. If things just work, they will be used and solve problems. This is my first attempt at something that just works.

I needed a solution that:

  • Was easy to add to my workflow
  • Wouldn’t break if it wasn’t in the workflow
  • Let me switch branches quickly
  • Let me commit and push only the logical units I decided on
  • Automatically detected what I consider to be the base WIP code
  • Didn’t commit that base WIP code, but committed everything else

To achieve this, I created three subcommands: astash, acommit, and acheckout.

Idea

My idea is to use stash to store my WIP base code while I commit everything else. The issue is that manually doing this becomes a pain—it’s extra work every time.

  • Start a stash session that marks the current files as base WIP
  • Each stash session is tied to its branch—only one session can exist per branch
  • When committing from a stash session, commit whatever files remain
  • When committing without an active stash session, commit normally
  • When you check out of a branch with an active stash session, automatically stash your changes
  • When you check out to a branch with an existing stash session, automatically restore that stash
  • When you check out to a branch without a stash session, don’t start one
  • Finish a stash session
astash

There are three commands start, list, finish.

start this command creates a metadata file that shows there is an active stash session currently in progress. It contains the name of the stash that is to be used and references the file where the stash data is stored. Once this metadata file is created, any of the other commands will act differently and automatically use stash instead of their default behavior. This allows you to work within a stash context without having to explicitly specify it each time.

finish this command deletes the metadata file that was created by the start command and reverts the commands back to their original state so they act like standard commands again. Essentially, it closes out your stash session and returns everything to normal operation mode.

list lists out all the branches that currently have active stash sessions enabled. This gives you a quick overview of which branches are operating in stash mode, making it easy to keep track of your work across multiple branches.

acommit

This command acts transparently and passes everything through to the commit command without any modifications or interference. It essentially serves as a wrapper that maintains all the functionality and arguments you’d normally use with a standard commit operation.

If there is a stash session active at the time you run this command, it intelligently handles the stashing workflow for you. This command automatically stashes the files that are designated to be stashed according to your stash session configuration, then proceeds to commit the remaining changes to your repository, and finally unstashes the code immediately afterward. The end result is that your working directory looks exactly the same as it did before you ran the command - all your uncommitted work-in-progress files are right back where they were. But now you’ve successfully committed a single logical unit of code to your repository without having to manually juggle the stash and unstash operations yourself.

acheckout

This command acts transparently and passes everything through to the checkout command without interfering with its normal operation or modifying any of its default behaviour.

If there is a stash session currently active on your current branch, when it switches to the new branch, it’ll automatically stash the code for you. This happens seamlessly in the background. If there’s no active stash session, it acts as a standard checkout command would, simply switching branches without any additional stashing behaviour.

Upon switching to the new branch, if there is a stash session active for that specific branch (meaning you had previously stashed work on that branch), it automatically restores the stash for you. This way, you can pick up right where you left off with your work-in-progress changes intact.

Example
git astash start 
git acommit -am "Add my changes"
git acheckout -b new-branch
git acheckout old-branch
git astash finish
git acommit -am "Final changes"
Repository

You can find the scripts at https://codeberg.org/that-guy-iain/git-atomic.

https://iain.rocks/blog/rejecting-rebase-and-stack-diffs-my-way-of-doing-atomic-commits
Finding broken migrations with Bisect
AI
I just watched Pauline Vos’ “The Business of Bisecting” talk, and one of the questions from the audience really stuck with me. During the section on automating good/bad commit checks with a script, someone asked if git passes the commit hash of the previous commit to the script, which it doesn’t. But the question was the wrong one. The real question should be “Can you get the hash of the previous commit?” Which you can. And it’s super useful if you want to check if a migration or something similar is broken.
Show full content

I just watched Pauline Vos’ “The Business of Bisecting” talk, and one of the questions from the audience really stuck with me. During the section on automating good/bad commit checks with a script, someone asked if git passes the commit hash of the previous commit to the script, which it doesn’t. But the question was the wrong one. The real question should be “Can you get the hash of the previous commit?” Which you can. And it’s super useful if you want to check if a migration or something similar is broken.

What is bisect

The best way to learn is to watch “The Business of Bisecting”, since that’s literally what I watched to learn. But here’s the quick version for those just wanting to read.

Bisect helps you trace through commits to find where something changed. It’s incredibly powerful and can save you hours of manually searching through code. Say you want to find when a test started breaking locally while still passing in CI — a frustrating scenario where the culprit isn’t immediately obvious.

You start by marking a commit as “good” where you know everything worked. This becomes your baseline. Then you check each commit to see if it works or not. Git uses binary search to efficiently narrow down the problem, so you won’t check every single commit. Mark each one good or bad, and git intelligently picks the next commit to check, cutting the search space in half each time. You can automate this entire process with a script, saving yourself hours of tedious work. A bash script handles all the repetitive tasks automatically. Once you’ve set it up, the script runs through each step without any intervention, freeing you up to focus on more important things.

Eventually, git bisect pinpoints the exact commit that introduced the bug. You can examine those specific changes and fix the issue much faster than hunting it down manually.

Scenario

You might need to retrieve an earlier revision when debugging issues or verifying backward compatibility. In this example, we’ll restore a database schema from a previous point in time, then run migrations against it. This helps us find migrations that break due to schema changes—like when a migration expects a column that no longer exists, or tries to create something that’s already there. Migrations can also break because of the data itself—unexpected NULL values, invalid foreign keys, or data that doesn’t conform to new constraints. You could solve this by restoring a full database snapshot with representative data and migrating it forward, but for our purposes, testing schema changes alone catches the most common migration failures without the overhead of maintaining complete database dumps.

Luckily, git is predictable and makes this manageable. Bisect just moves where you are in the branch you’re on during bisect so HEAD is the current place in the branch, just like during normal git operations. Any command you’d normally use with HEAD works here without special modifications. We can fetch commit IDs using git rev-parse HEAD and git rev-parse HEAD~1. Store them in variables, use git checkout to navigate back to the previous revision, then run whatever commands you need. Once everything’s working, checkout the current revision again and complete your bisect operation. This straightforward approach gives you full control while leveraging git’s reliable version control.

In my example, I’m using doctrine migrations within a Symfony app but change to your setup.

#!/bin/bash

## Get commit hashes
CURRENT_COMMIT=$(git rev-parse HEAD)
LAST_COMMIT=$(git rev-parse HEAD~1)

## Setup previous commit
git checkout "$LAST_COMMIT"
composer install --no-interaction > /dev/null 2>&1
./bin/console doctrine:database:drop --force --no-interaction > /dev/null 2>&1
./bin/console doctrine:database:create --if-not-exists --no-interaction > /dev/null 2>&1
./bin/console doctrine:schema:drop --force > /dev/null 2>&1
./bin/console doctrine:schema:create > /dev/null 2>&1
./bin/console doctrine:migrations:version --add --all --no-interaction > /dev/null 2>&1

## Setup current commit
git checkout "$CURRENT_COMMIT"
composer install --no-interaction > /dev/null 2>&1

# Actual test
./bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration

MIGRATION_EXIT_CODE=$?
if [ $MIGRATION_EXIT_CODE -eq 0 ]; then
    # Migration succeeded: This is a "Good" commit
    exit 0
else
    # Migration failed: This is a "Bad" commit
    exit 1
fi
Example

Blog posts are cool, but many of us learn by doing. I created a branch on my BillaBear project from a stable tag and added a breaking migration. Checkout the repository and run the following commands to see it work:

git clone https://github.com/billabear/billabear.git
cd billabear
git checkout bisect-migrations-example
git bisect start HEAD  # Marks the current commit (HEAD) as the starting point.
git bisect bad         # Explicitly marks the starting point as "bad".
git bisect good HEAD~20 # Marks the commit 20 commits prior to HEAD as "good".
git bisect run ./bisect-script.sh

You can then move the broken migration commit which is 03e7e4537f3c7be3540fd637b1fe48eca1b156f9 around using rebase and play around.

https://iain.rocks/blog/finding-broken-migrations-with-bisect
AI is a Junior Dev and needs a Lead
AI
Using AI to help with programming is becoming increasingly popular as time goes on, with developers across the industry embracing tools like Junie, GitHub Copilot, ChatGPT, and Claude to accelerate their workflow. We’re seeing plenty of horror stories where things go spectacularly wrong - from subtle bugs that slip through code reviews to complete architectural disasters that require massive refactoring efforts. Security vulnerabilities introduced by AI-generated code have made headlines, and there are countless tales of junior developers blindly copying and pasting AI suggestions without understanding what the code actually does.
Show full content

Using AI to help with programming is becoming increasingly popular as time goes on, with developers across the industry embracing tools like Junie, GitHub Copilot, ChatGPT, and Claude to accelerate their workflow. We’re seeing plenty of horror stories where things go spectacularly wrong - from subtle bugs that slip through code reviews to complete architectural disasters that require massive refactoring efforts. Security vulnerabilities introduced by AI-generated code have made headlines, and there are countless tales of junior developers blindly copying and pasting AI suggestions without understanding what the code actually does.

But who’s really at fault here? Is it the AI tools themselves, user inexperience, or deeper integration issues within our development processes? To answer this question, I’ve deliberately tested AI coding assistants across various scenarios. And I think I’ve figured it out, it’s a junior dev that just needs a lead dev

Starting Out

When I first started exploring AI and coding, I began asking AI systems questions about programming challenges. These systems consistently provided clear explanations and working code examples. Over time, they became a reliable first port of call for tackling problems—something I could count on to break down concepts and suggest practical solutions.

After a while, the results became increasingly reliable and genuinely impressive. GitHub Copilot developed remarkable auto-complete capabilities, effortlessly filling in boilerplate code that followed established patterns in my work. It began to understand the context of what I was building, suggesting not just syntactically correct code, but code that actually made sense within the context of that area in my project. The AI could recognise when I was writing a data mapper like the other data mappers and would see all the fields that need to be mapped and just provide it as an auto-complete.

What started as a novelty gradually transformed into a useful time-saving measure that I really like and miss when I don’t have it.

Enter Junie

I’m not the fastest adopter of new things ever, and I’m often a bit resistant to change until something has proven itself to be genuinely worthwhile rather than just another passing fad. So it took me a few months to even try out Junie, despite all the hype and constant chatter from other developers who seemed to think it was the second coming of software development tools. (You know who you are)

And honestly, at first, it was absolutely rubbish. Complete and utter garbage, if I’m being frank. I genuinely hated it with a passion. It ate up all my quota within the first few days of testing, delivered broken code that wouldn’t pass the build no matter how many times I tweaked the prompts, and generally wasted my precious time that I could have spent actually writing working code myself. It took longer and produced less.

But one thing was absolutely clear, the other developers I knew were way better at using AI than me and they said they were getting genuinely good results from their interactions. These weren’t just casual users either - these were experienced programmers who I respected, people whose technical judgement I trusted. They were raving about how AI was helping them solve complex problems, generate clean code, and even debug tricky issues that would normally take hours to resolve. Meanwhile, I was struggling to get anything useful out of the same tools they were using. The disconnect was obvious. So I had to figure out what the problem was - was it my prompting technique, my expectations, or something else entirely?

It’s a Junior Developer and needs a Lead Developer!

At first, I was asking it to do things like I would ask a senior developer on my team. I expected it to know certain things and just understand how to develop from start to finish. I’d throw complex requirements at it, assuming it would grasp the nuances of our codebase, understand our architectural patterns, and know exactly which libraries we preferred to use. I thought it would inherently understand our coding standards, our deployment processes, and the subtle business logic that had evolved over months of iterations. Basically, I was treating it like a seasoned colleague who had been working alongside me for years, someone who could read between the lines and fill in the gaps without needing extensive context or detailed explanations. So even though I was giving it junior-level tasks, I was treating it as a senior developer doing super-easy obvious task.

The first task I gave it was to refactor DTOs in PHP from the old legacy way of creating them to using new features such as readonly classes and constructor promotion with public properties instead of getters. For an experienced developer, that’s an easy task that is just long and boring. Junie, on its first try, did about 10 out of 100 or so classes, gave up, and pretended like it was all done. The build was broken and it was so half done that even the things that it touched weren’t worth dealing with.

To be fair, everyone said it was too much, and they were absolutely right from the start. I was being far too ambitious with the scope, trying to tackle everything at once when I should have known better. The breakthrough came when I started doing it in a much smaller namespace and methodically moving through the different namespaces one by one, and suddenly it was able to do the job properly. The key was breaking everything down into manageable pieces rather than attempting some grand, sweeping implementation.

If I focused on specific small chunks of functionality - really drilling down into the individual components and tackling them separately - it could actually do it. The difference was night and day once I adopted this more granular approach. So I could now get it to do very basic things reliably, and more importantly, it was clear what was working and what wasn’t. Each small success built upon the previous one, creating a solid foundation that I could actually understand and debug when things went wrong.

Treating it like Jr Dev

I then remembered all my time being a lead developer with multiple junior developers working under me. The memories came flooding back - countless code reviews, debugging sessions that went on for hours, and those frustrating moments when I’d discover someone had pushed untested code to production. And I realised all the things I complained about, all the stupid AI blow-ups, the overly complex solutions to simple problems, the failure to consider edge cases, the tendency to reinvent the wheel instead of using established patterns, etc all had one thing in common. They’re exactly the same things junior devs do because they don’t know any better. They haven’t yet developed the experience to recognise when they’re overengineering something, or when a seemingly clever solution will create more problems down the line. Just like junior developers, AI systems lack the hard-won wisdom that comes from years of making mistakes and learning from them.

AI, if left on its own, will choose the lazy way every single time. It will take shortcuts that seem clever in the moment but create technical debt that’ll haunt you for months. It will choose the risky way, implementing solutions that work in the happy path but completely fall apart the moment you encounter edge cases or unexpected user behaviour. It’ll do downright stupid things that make you question how something so supposedly “intelligent” can be so utterly clueless about basic logic and common sense. Because at the end of the day, it’s just a junior dev—one that’s incredibly fast at writing code but lacks the wisdom, experience, and critical thinking skills that come from years of making mistakes and learning from them. It doesn’t understand context, can’t read between the lines, and has no intuition about what could go wrong.

So I decided I was going to do what I did to junior developers: be specific about what they are to do, break down the task into manageable steps, provide clear user stories or BDD scenarios and comprehensive guidelines. And even then, ensure that the task isn’t too big or overwhelming for them to tackle in one go. And most importantly, code review them thoroughly.

I’ve learnt from years of mentoring that vague instructions like “make this better” or “fix the performance issues” are absolutely useless. Junior developers need concrete, actionable guidance - they need to know exactly what success looks like. The same principle applies here. Instead of giving broad, sweeping requirements, I started crafting detailed specifications that left little room for misinterpretation. I’d outline the acceptance criteria, provide examples of expected behaviour, and even include edge cases they should consider. This approach transforms what could be a frustrating guessing game into a clear roadmap with defined milestones.

I went from staring at dodgy code whilst twiddling my thumbs to producing production-ready software—and suddenly found myself far busier. Whilst Junie worked on the tasks I’d assigned, I was either reviewing code that Junie would then address or crafting specifications for the next piece of work. It transformed my role overnight into something resembling a lead developer: I decide how the work gets done and ensure quality standards, whilst others handle the actual coding.

What I did Guidelines

One of the first steps to mastering coding agents is establishing clear guidelines. Junie makes this straightforward with the .junie/guidelines.txt file. It even helps you get started with a prompt from the Master Junie section. From there, you simply edit and expand the guidelines, describing how each component of your system works and how to build features that integrate seamlessly with it.

The guidelines are incredibly powerful and can work seamlessly with MCPs (Model Context Protocol). The integration possibilities are genuinely impressive once you get everything configured properly. For example, I have it set up so that it automatically commits the changes to a new branch with a descriptive commit message, pushes it directly to GitHub, and then requests a review from both me and GitHub Copilot. It’s quite satisfying to watch the entire workflow execute automatically - from code generation to version control to review requests - all without manual intervention.

The only minor hiccup I’ve encountered is that the Copilot review request fails about 50% of the time, though I suspect that’s a race condition on GitHub’s side.

Scenario

Having learnt to love BDD, I use scenarios quite a lot as a way of defining a feature and how it should work. There’s something incredibly powerful about the structure and clarity that BDD scenarios provide - they force you to think through the actual user journey and what really matters. I’ve found it’s absolutely brilliant for explaining to everyone, technical or non-technical alike. Product managers get it, developers understand the requirements clearly, and even stakeholders who’ve never written a line of code can follow along and provide meaningful feedback.

So I create a comprehensive feature file with detailed scenarios for how this feature should work, covering not just the happy path but also edge cases and error conditions. Each scenario follows the classic Given-When-Then format, which creates a shared language that bridges the gap between business requirements and technical implementation. It’s become an essential part of my workflow because it eliminates so much ambiguity and miscommunication that typically plagues software projects.

Prompt

Then I craft the prompt explaining what we’re doing in clear, precise language. If it’s adding a feature, I take the time to explain what the feature is for, providing context about why it’s needed and how it fits into the broader application architecture. I describe how it should work in a sentence or two, being specific about the expected behaviour and any edge cases that need to be considered. I then provide a comprehensive task breakdown, methodically listing out what things need to be done, prioritising them in logical order, and noting any dependencies between tasks. This structured approach ensures nothing gets overlooked and gives the Junie a clear roadmap to follow from start to finish.

We are adding a feature to allow users to see the subscription stats. For now we're just going to add the backend part.

Users need to see how many subscriptions they have for each month. They need to see how many are existing subscriptions that rolled over from that month, how many are new subscriptions, how many are upgrades, how many are downgrades, how many churned, and how many users came back and reactivated.

Tasks:
- Run behat features/Stats/Subscription/new_subscription_stats.feature and add all the steps
- Create a repository class that fetches all the data using SQL/DQL and not the QueryBuilder
- Add the backend app end point that returns the stats

So while still not totally spoonfeeding it, it’s getting spoonfed a lot.

Result

The result from this was code that was almost ready for production, which frankly surprised me given how quickly it all came together. It needed your standard code review and removal of the silly things - you know, those little shortcuts and quick fixes that always creep in when you’re in the flow of getting something working. There were a few hardcoded values that needed to be made configurable, some error handling that could be more robust, and the usual suspects like inconsistent variable naming and missing documentation.

After 2-3 thorough code reviews and feedback sessions with the team, where we caught the edge cases I’d missed and polished up the logic, the code was all done and properly tested. The backend was solid, performant, and ready to handle whatever we threw at it. With that foundation firmly in place, I moved on to the frontend part, which promised to be an entirely different beast altogether.

https://iain.rocks/blog/ai-is-a-junior-dev-and-needs-a-lead
How to Create Unbreakable Job Security: A Software Developer’s Guide to Making Yourself Indispensable
SatireSoftware Design
So you want job security in software development. Whilst your colleagues build maintainable, scalable systems that any competent developer could understand and modify, you’re about to learn how to create complex systems that your organisation will never dare let you go. After all, who else could possibly understand the intricate web of dependencies you’re about to weave?
Show full content

So you want job security in software development. Whilst your colleagues build maintainable, scalable systems that any competent developer could understand and modify, you’re about to learn how to create complex systems that your organisation will never dare let you go. After all, who else could possibly understand the intricate web of dependencies you’re about to weave?

The cruel irony of software development is that the better you build something, the less valuable you become. Well-architected systems with clear documentation, comprehensive tests, and intuitive design patterns practically maintain themselves. A junior developer can jump in, understand the codebase quickly, and start contributing meaningful changes. Meanwhile, the poorly-built system that requires constant nurturing, mysterious deployment rituals, and deep knowledge of its Byzantine internals? That’s the one that comes with job security.

The Microservices Mirage: Sharing is Caring (Especially Databases)

Nothing says “I’m a forward-thinking developer” quite like jumping on the microservices bandwagon. But here’s where most developers go wrong: they actually try to make their microservices independent. How amateur.

The real secret to microservices mastery is ensuring they’re all intimately connected through shared databases. Think of it as creating a tangled web where touching one service sends ripples through seventeen others. When someone asks why your “Order Service” and “User Service” both directly access the same customer table, just smile and mutter something about “data consistency” and “avoiding duplication.”

This approach has multiple benefits. First, you’ve created a distributed monolith that combines all the complexity of microservices with none of the benefits. Debugging becomes an archaeological expedition where developers must trace through multiple services, each with its own logs, just to understand why updating a user’s email address broke the inventory system. Which is all work, which means they need you!

Second, database migrations become adventures in coordination. Want to add a column? Better hope all seventeen services that touch that table are ready for the change. The deployment orchestration alone will require a PhD in distributed systems and considerable patience. Naturally, you’ll be the only one who truly understands the intricate dance required to keep everything running.

The shared database approach also creates cascading failures. When one service locks a table for too long, watch as your entire system starts to fall apart. Do you know how long it will take them to figure out the root cause? Months! And then it’ll take even longer to fix that mess! And you have all the domain knowledge!

Jeff Bezos understood this paradox perfectly and ruined everything in 2002 with his infamous API mandate. His memo banned “direct reads of another team’s data store, no shared-memory model, no back-doors whatsoever” and forced teams to “communicate with each other through these interfaces.” How thoughtless of him to mandate clean, maintainable systems with well-documented APIs – completely destroying the time-honoured tradition of job security through incomprehensible complexity. Coincidentally, Amazon isn’t exactly famous for its job security. Funny how that works out.

The Art of Nano-Services: When Smaller is Not Better

Whilst your competitors are building reasonably-sized services that handle coherent business domains, you’ll be pioneering nano-services. Why have one service handle user authentication when you can have five?

Picture this architectural approach: a “User Login Service” that validates credentials, which calls the “Password Validation Service,” which in turn calls the “Password Complexity Checker Service,” which depends on the “Special Character Counter Service.” Each service runs in its own container, has its own database, and requires its own monitoring, logging, and deployment pipeline. Think of the work just from maintaining the scaffolding!

The result of this approach is that you’ve transformed what should be a simple function call into a distributed transaction spanning multiple network hops. A user login now requires coordinating half a dozen services, each with its own potential failure modes. Network latency? Check. Partial failures? Double check. Debugging nightmares? Triple check.

When someone questions why you need a separate “Email Validation Service” that does nothing but check if an email contains an @ symbol, you can launch into an explanation about the “single responsibility principle” and “bounded contexts.” Keeping your coworkers baffled will keep you on the payroll.

The beauty of your architectural masterpiece truly shines during deployment. What should be a simple git push has become a 47-step process requiring seventeen environment variables and a post-deployment ritual involving manually checking six health endpoints. When junior developers suggest automation, you can explain that the current process ensures “proper validation.” After all, if deployments were simple, anyone could do them – and then what would you do with your Tuesday afternoons?

And after a year or two of your nano-services causing performance issues, you can swoop in as the hero who “optimises” the system by merging your “Email Validation Service” and “Password Validation Service” into a single “User Validation Service.” You’ll present impressive metrics showing how you’ve eliminated 200ms of network latency per request and reduced deployment complexity by half. Management will praise your “performance optimisation” and “architectural insights,” completely oblivious to the fact that you’re the one who created the problem in the first place. It’s the perfect crime: first, you create the mess, then you get promoted for cleaning it up.

Database Roulette: Choosing the Wrong Tool for Every Job

Data modelling is absolutely crucial, which is why you should choose the wrong one at every opportunity. Store highly relational e-commerce data in a document database, cram time-series metrics into a traditional RDBMS, and use a graph database for simple key-value lookups. When your team spends months wrestling with data consistency issues and performance problems, remember: that’s not poor architecture, that’s job security in action.

The real masterstroke is using a NoSQL database for highly relational data. Take your e-commerce platform’s order system – a perfect candidate for a relational database with its clear relationships between customers, orders, products, and payments. Instead, cram it all into MongoDB and watch as your developers struggle to maintain data consistency without foreign keys, transactions, or joins.

Don’t forget to sprinkle in some exotic databases that require specialised knowledge. Use CouchDB for something that would work perfectly fine in Redis. Implement your search functionality with Elasticsearch combined with a custom graph database built from scratch – after all, anyone can learn Elasticsearch, but who else understands your bespoke indexing algorithms? The more obscure your technology choices, the more indispensable you become. When management suggests simplifying the stack, you can explain that migrating away would take months and risk data integrity.

As an added bonus, all these exotic database technologies look brilliant on your resume – who wouldn’t want to hire someone with experience in Neo4j, CouchDB, InfluxDB, and Cassandra? Meanwhile, migrating away from any of these choices becomes a herculean task. Want to move that graph database storing user sessions back to Redis? That’ll be a six-month project requiring careful data migration, extensive testing, and probably a few outages along the way. And those projects look good on your resume. You’ve essentially created technological quicksand – the more they struggle to simplify, the deeper they sink.

The Network is Your Enemy (Or Your Best Friend)

By combining shared databases, nano-services, and a polyglot persistence strategy, you’ve created something special: a system where every operation requires multiple network calls across different technologies. A simple user registration now involves calls to ten different services, each potentially running on different infrastructure, with different latency characteristics and failure modes.

The distributed nature of your architecture means that debugging becomes an exercise in distributed systems archaeology. When something goes wrong – and it will go wrong – tracking down the root cause requires correlating logs across dozens of services, understanding the timing of various network calls, and having an intimate knowledge of how all the pieces fit together. Coincidentally, you’re the only person who possesses this knowledge.

Conclusion: Your Legacy of Indispensability

You’ve successfully created a system that is simultaneously over-engineered and under-architected, complex and fragile, modern and unmaintainable. Your microservices share databases like a commune shares resources, your services are so small they make atoms look chunky, and your database choices would make a data modelling textbook burst into flames.

The result is a Byzantine system that requires your constant attention and expertise to keep running. Every deployment is an adventure, every bug fix is a journey through multiple services and databases, and every new feature requires navigating the labyrinthine architecture you’ve constructed.

Your colleagues may grumble about complexity, your operations team may curse your name during outages, and your successors may spend months just trying to understand what you’ve built. But suggesting getting rid of you is something they would never do.

So sleep soundly, knowing that your job security is as solid as your architecture is fragile. You’ve achieved the ultimate goal: building a system that works just well enough to stay in production, but is complex enough that no one else dares touch it. You’re not just a developer – you’re an artist, and your medium is technical debt.

Welcome to the pantheon of indispensable developers. Your legacy will live on in every 3 AM pager alert, every developer’s frustrated sigh, and every architect’s nervous breakdown when they try to draw your system on a whiteboard.

https://iain.rocks/blog/how-to-create-unbreakable-job-security
Creating Dynamic Billing Workflows With Symfony
PHPSymfony
One of the core concepts within BillaBear is creating workflows for events such as creating or cancelling a subscription and receiving payments. There are often many things that need to happen during these process and if one fails you don’t want to carry on with the others until the failed process is fixed and you want to be able to continue a failed process with ease instead of complex development work. To help BillaBear users with their processes we’ve created the ability to add custom steps to the workflows. Here we’re going to explain how we used Symfony Workflows to create dynamic workflows.
Show full content

One of the core concepts within BillaBear is creating workflows for events such as creating or cancelling a subscription and receiving payments. There are often many things that need to happen during these process and if one fails you don’t want to carry on with the others until the failed process is fixed and you want to be able to continue a failed process with ease instead of complex development work. To help BillaBear users with their processes we’ve created the ability to add custom steps to the workflows. Here we’re going to explain how we used Symfony Workflows to create dynamic workflows.

Why not normal webhooks?

One of the first questions that really needs to be answered is, why? If BillaBear supports standard webhooks, which it does, why would you ever need to have a webhook that blocks your workflow if it fails? The simple answer to that is, it’s about how important the webhook actually are. For many webhooks if we don’t receive one or it fails, often it’s not a big deal. But sometimes, it really is. For example, if when creating a subscription you need to create resources so the customer can use the subscription and they can’t use it without those resources. That isn’t something that should be handled in a fire and forget webhook. That’s something you can to keep track of if it fails and rerun it once you’ve fixed whatever broke.

And maybe you want to integrate deeply with a third party so you would want to use their sdk in a custom transition handler.

Places

To ensure the best overall experience for BillaBear users we added two different ways of creating custom Places. You can define them via using code or via the BillaBear admin system. Both have their pros and cons.

Code defined Place

It’s possible to define a place in the code. To do this you create a class in the Custom namespace, a namespace where it’s safe to make custom changes without BillaBear overwriting it in future releases, that implements BillaBear\Workflow\Places\PlacesInterface. This is the same interface that it’s used by the entity for the Place that is managed via the Admin UI.

This class will allow you to define:

  • The name of the place
  • The piority of the place aka where it should be in the order
  • The workflow that it is for
  • The name of the transition for the place
Pros
  • Easy to maintain for development
  • Ability to define a custom event handler for the transition
Cons
  • Can’t be enabled/disabled without code changes and a deployment
  • Can’t reuse the dyanmic event handlers
Admin UI dedined Place

It’s possible to add extra places within the workflow via the admin system, this can be very useful for creating webhooks for adding steps to your processes.

Screenshot of BillaBear

Creating a place via this will allow you to:

  • Define the name of the place
  • The pre-defined event handler
  • Set options for the event handler
Pros
  • Ability to disable and enable without development work
  • Can use the dynamic event handlers.
Cons
  • Can’t be tracked by the development team
  • Can’t use a custom event handler
Dyanmic Transition Handlers

BillaBear ships with some dynamic event handlers but you’re also able to create your own by implementing BillaBear\Workflow\TransitionHandlers\DynamicTransitionHandlerInterface any class that implements this interface will automatically be collected into the DyanmicTransitionHandlerProvider.

interface DynamicTransitionHandlerInterface
{
    public function getName(): string;

    public function getOptions(): array;

    public function execute(Event $event): void;

    /**
     * Added to allow the handler to have the transition to get the handler options. Otherwise,
     * the only other option is to fetch the workflow transition in the workflow processor, and
     * it makes no sense to fetch the data in two different places. And this allows more overall
     * flexibility since they'll have access to all the data when executing the handler.
     */
    public function createCloneWithTransition(WorkflowTransition $transition): DynamicTransitionHandlerInterface;
}
Building the Workflow

Now we get to what I consider the coolest part. Symfony Workflow allows you to define workflows via Yaml and other configuration options. But since this is all dynamic and can change from run to run and things can be added without changing the config, that’s pretty much out of the window. Which meant I was going to have to build the workflows on the fly and that’s just cool in my book.

To create the workflow we use the WorkflowBuilder which does the following tasks.

It fetches the places for the workflow from the PlacesProvider which returns the places in order. Then it’s a case of building building the Symfony Workflow Definition.

  • The first argument for the Definition is an array of strings that contain the names for each Place.
  • The second argument for Definition is an array of Symfony Workflow Transitions with the transitions building being linked from each other in order as being in the array.
  • The third argument is an array of strings with the starting positions for a workflow. In our case we just take the first value from the array of Place names since it’s in order.
  • The fourth and final argument is a MetadataStoreInterface, which we just the use the default InMemoryMetadataStore with two empty arrays and an instance of
        $definition = new Definition(
            $this->getPlaceNames($places),
            $this->getTransitions($places),
            [$this->getPlaceNames($places)[0]],
            new \Symfony\Component\Workflow\Metadata\InMemoryMetadataStore([], [], new \SplObjectStorage())
        );
Getting the place names
    private function getPlaceNames(array $places): array
    {
        return array_map(function (PlaceInterface $place) {
            return $place->getName();
        }, $places);
    }
Building the Transitions

The transitions are built by looping through the places and seeing if the place is enabled. If it’s not, then there is much use for aa transition. It then uses the last previous place as the from place and the current place as the to place.

    /**
     * @param PlaceInterface[] $places
     *
     * @return Transition[]
     */
    private function getTransitions(array $places): array
    {
        $output = [];
        $from = null;
        foreach ($places as $place) {
            if (!$place->isEnabled()) {
                continue;
            }
            if ($from instanceof PlaceInterface) {
                $output[] = new Transition(
                    $place->getToTransitionName(),
                    $from->getName(),
                    $place->getName(),
                );
            }

            $from = $place;
        }

        return $output;
    }
Adding event handlers

Since the workflow system is built ontop of events, we need to add events handlers to handle the transitions. We only do that for workflow transitions that are coming from the database. Which is why there is a check to see if it’s coming from the database or not. Then it’s just a case of following the naming convention that Symfony has.

    private function addEventHandlers(WorkflowType $workflowType, array $places): void
    {
        foreach ($places as $place) {
            if ($place instanceof WorkflowTransition) {
                $handler = $this->dynamicHandlerManager->createHandler($place->getHandlerName(), $place);
                $this->eventDispatcher->addListener(sprintf('workflow.%s.transition.%s', $workflowType->value, $place->getToTransitionName()), [$handler, 'execute']);
            }
        }
    }
Conclusion

This is how we’ve built the ability to have custom dynamic billing workflows using the Symfony Workflow component.

https://iain.rocks/blog/creating-dynamic-billing-workflows-with-symfony
The Reality of Not Invented Here
ProductDevelopment Process
When I first heard of Not Invented Here (NIH) as a junior developer, I always thought it meant that the company wouldn’t use anything external. I thought it would be a case that every tool would be developed internally. I thought it was going to be driven purely by ego that they thought they could do better. And for a long time, I thought I never worked somewhere that had that problem. The reality of NIH is completely different from what I expected and I’ve been encountering it for far longer than I thought.
Show full content

When I first heard of Not Invented Here (NIH) as a junior developer, I always thought it meant that the company wouldn’t use anything external. I thought it would be a case that every tool would be developed internally. I thought it was going to be driven purely by ego that they thought they could do better. And for a long time, I thought I never worked somewhere that had that problem. The reality of NIH is completely different from what I expected and I’ve been encountering it for far longer than I thought.

Large Companies

For this post, I’ll be focusing mostly on small to medium-sized teams. The motivation at larger enterprise companies is often different. At enterprise companies, it could just be an issue that they can’t or don’t want to jump through the hoops it takes to get a third-party vendor approved by procurement.

In other companies, they could be motivated by promotion. A case of they’re more likely to move up the ladder if they’ve been on a team that created a new project. Whether or not it was truly needed.

These seem like valid reasons. It’s valid you want to build something because it’s easier than getting a third-party vendor approved. And it’s fair that people want to look good at a company. If the company promotes people based on that criteria then the company made the decision for this approach.

Using Libraries And Other Tools

One of the biggest things is that teams that are engaging in NIH are generally using lots of external libraries, frameworks, and tools. We as a development community have done so well in frowning upon homebaked frameworks that it’s extremely rare that you even hear of a team that uses a homebaked framework. Libraries are generally used liberally. And this is why you often don’t realise you’ve got an NIH problem.

Motivation

When I first started out, I thought the motivation behind NIH was ego. And that they thought they could deliver something better than what others were delivering. Nearly every time I’ve seen NIH in action, it’s been caused by people having good intentions. They think they will save the company money by building it themselves. They think we don’t need all the features, they can get away with having a subset of the features. So they often don’t even think it’s an NIH issue. Because it’s not about the fact it wasn’t invented there. It’s about trying to save the company money and that they honestly don’t think they need all the fancy features that while they would be good they don’t need them.

An Example

For this part, I’ll use feature flags as the example feature and LaunchDarkly as the third-party service. Simply because this is the feature that I’ve seen NIH happen with the most that caused so much hassle. And it’s very easy to see why teams fall into this trap with this specific feature. However, I’ve seen this happen with major functionalities as well and it often happens with major functionalities.

A quick introduction to what a feature flag is. A feature flag is simply a flag for whether or not a feature is to be used or not. It is core it’s an if statement. It’s very easy to see why development teams think they can just build their own. It’s just an if statement. We can store the value in the configuration file or in the database. Super simple.

Then you have something like LaunchDarkly, a SaaS platform that just focuses on feature flags. It’s got more features than you can even imagine using.

What generally happens is the development team thinks we can save $50-100 a month if we build the feature flags ourselves. It’ll take 2-3 days and then it’s done. However, it takes about 6-7 days. It goes through original development for 2-3 days, goes to code review, takes 1-2 days to get through code review, and then 1-2 days to get QA. With even a very low salary of just $50k per year for each staff member, this feature just cost a minimum of $1,000 and probably more in the range of $1,500-2,000. For the original development of a bare-bones feature flag system. They could have paid for 3 seats on LaunchDarkly’s Pro plan for 2 for that.

But of course, it needs more than just the original development. Depending on how the feature flag system was developed there are times when it needs development work each time a flag is to be changed. If it’s based on ENV variables then it needs deployments and all sorts of stuff. And often it results in performance issues at a moderate scale. So then you have all the development work to investigate why such and such feature is slow. And then it turns out to be the feature flag system. So then you have fix the performance issue. Say you have 1-2 days to investigate the performance issues, 2-3 days to develop a fix, 1-2 days for code review, and 1-2 days for QA. We’re back to another $2,000-$3,000 in cost. For a feature that isn’t part of your core functionality and doesn’t really help you move the needle.

Whereas, LaunchDarkly, purely focuses on feature flags. That is their business. They have lots of developers purely focused on it. If a performance issue comes from feature flags that is their responsibility to fix. You get all the extra functionalities that LaunchDarkly brings and not a bare-bones feature flag system.

Based on a below-market average salary of $50,000 a year, it’s easy to estimate that a bare-bones feature flag system built to save the company would cost $5,000. While if the company outsourced it to LaunchDarkly they could get 10 seats of LaunchDarkly’s Pro plan for 2 years and get a state-of-the-art feature flag system. So while it started with good intentions, the reality is it’s a false economy where the company spends more money trying to save money.

Conclusion

The reality of NIH in most development teams in this era of development isn’t about inventing everything in-house, it’s trying to save costs by building something themselves while forgetting that their time costs the company money.

Often companies end up with subpar bare-bones functionality that needs maintenance and increases on-boarding. This doesn’t just apply to features like feature flags but includes things such as billing and finance, emails both transactional and marketing, business intelligence, logging, monitoring, etc. This list goes on where companies build their own and end up with something that costs them more and delivers less than if they just got a solution from a third party.

https://iain.rocks/blog/reality-of-not-invented-here
Design Decision Separate Dtos
Design Decision
One of the decisions that had to be made early on was how to handle the input and output for the API endpoints. There are a few options such as serialising the entity and serialisation configuration is used to decide what is shown and when. Another option is to create a defined DTO that defines what should be there.
Show full content

One of the decisions that had to be made early on was how to handle the input and output for the API endpoints. There are a few options such as serialising the entity and serialisation configuration is used to decide what is shown and when. Another option is to create a defined DTO that defines what should be there.

Decision

The decision was to create separate DTOs for each endpoint and not to serialise the entity objects. This means each endpoint’s request body has its own DTO, and each endpoint’s response body has its own DTO. And DTOs are not reused.

Note, that generic model DTOs are shared. So Subscription, Customer, etc. are not redefined and are reused so that if the Customer model gets a new field it’s replicated across all endpoints that have the customer data as part of its response.

Overall, I don’t think there is a right or wrong answer between which option to choose. They all have their pros and cons.

Why not Serialise the entity?

One of the core reasons not to serialise the entity is that it’s far too easy for private internal data to be leaked by adding it and forgetting to add the ignore configuration. An example is a field that contains the link to who created a refund. This information is useful for internal auditing purposes however this isn’t information something you would want to share.

Another reason it was decided to avoid this approach is that it generally results in using serialisation groups to decide which information is to be returned for each view. This can result in a complex array of groups and be confusing to which groups a new field should be added. This can also lead to information being shared when it wasn’t meant to be shared.

The benefit of this approach is that you have a single source of truth when it comes to the entity. It contains the entity data as well as the serialised format data.

Defined DTOs

One of the benefits of defining a DTO is that you explicitly define which data is there and you must explicitly map the data. This means that leaking data accidentally isn’t possible without actively making mistakes. You have to actively accidentally add a field you didn’t mean to, you have to actively map the field you didn’t mean to. This means while it’s possible it’s generally a human error of making a mistake either in the ticket creation or ticket execution.

It could also be argued that this enables a separation of concerns, the entity is only concerned with the entity data and the DTO is only concerned with the serialised data format.

One of the downsides of this approach is that you have to create a class that is very similar if not almost identical to the entity. This can feel monotonous.

Shared Or Separate DTOs

Once it was decided to create defined DTOs to contain the data format for serialisation the next question is if there should be DTOs that are shared or if each endpoint should have its own.

The Shared DTOs option is the easiest option to start off with, you create a DTO and if another endpoint has the same needs you just use that. And for the most part, it is the least time-consuming option. However, in edge cases where after an extended period of development you find that you need to add a field to only one endpoint and that field can’t exist in any other endpoint, things can get messy. You either end up creating a separate DTO for that endpoint which could lead to confusion since that endpoint is no longer consistent and that can lead to potential bugs with people not realising that single endpoint is different from the rest. Or you end up using serialisation groups and you introduce a mesh of the two options and end up with the original problems of the serialisation groups and the problems of defined DTOs. As well as running into the same issue when it comes to validation groups for request bodies.

The Separate DTOs option is the more time-consuming option. It can also feel messy since it feels like you’re breaching DRY (Don’t Repeat Yourself). This option provides the security that you know exactly what is being returned or sent for each endpoint. You can ensure easily that request bodies have the correct validation that is needed. There are many cases where one form needs one set of validation because it’s creation but those rules don’t exist when doing an update. The main downside of this approach is that it’s more time-consuming and it’s also easier to forget to add a rule or data to a DTO since you could end up with 4-5 request bodies that need to validate data that is reused.

Generic Response DTO Models

The reason for using generic response DTO models is that for the most part, there are models within your API response that should always be the same. If your API endpoint response contains customer data it should contain the same customer data that all the other API endpoints contain that way the response for customers is consistent and people can rely on that data being there.

This provides a middle ground between completely shared DTOs and completely separate DTOs. Where the endpoint defines which data models it needs and those data models are shared.

https://iain.rocks/blog/design-decision-separate-dtos
Design Decisions:Throw Custom Exceptions
Software DesignDesign Decision
Decision
Show full content
Decision

A Parthenon method should not throw exceptions that don’t belong to Parthenon. It should catch all third-party exceptions and then throw a Parthenon-owned exception with the third-party exception passed to the new Parthenon exception. If the third-party exception contains valuable custom information for the exception it should be available via the Parthenon exception.

Reasoning

One of the ways implementation details of an interface can be leaked are by third-party exceptions. If the client code catches and handles a third-party exception then they’re coupled to the third-party code. The aim of Parthenon is that everything is replaceable with your own implementation having parts of Parthenon code depend on third-party exceptions - which would happen if Parthenon interfaces threw third-party exceptions.

And for many parts of Parthenon, the code is usable/extendable/etc by user developers to develop custom applications, if they depend on the third-party exception then their code also becomes dependent on the third-party code. This means if in the future there were to be a change in the third-party code or how the Parthenon code worked internally it would become a breaking change.

The reason to pass the third-party exception to the new exception is that the third-party exception will contain valuable information about what happened and we don’t want to lose that. User developers would still be able to use the third-party exception, however, it becomes more obvious that such coupling is fragile. And the custom exception should be able to provide the same information.

TL;DR - Removes the ability to become dependent on third-party code which improves flexibility.

Support Implications

This means if someone reports:

  • A Parthenon interface throws a third-party exception, it must be changed to throw a custom one.
  • A Parthenon exception does not contain all the information that is available in the third-party exception, it must be added.
https://iain.rocks/blog/design-decisions-throw-custom-exceptions