From pedantic-coder
This skill should be used when the user is writing parallel code paths (CRUD operations, event handlers, API endpoints), implementing matching pairs (create/destroy, open/close, serialize/deserialize), or when similar operations have dissimilar structures. Covers structural mirroring, signature consistency, and the rule that parallel things must look parallel.
npx claudepluginhub oborchers/fractional-cto --plugin pedantic-coderThis skill uses the workspace's default tool permissions.
If two functions do analogous work, they must have analogous structure. Same parameter order. Same return shape. Same error handling pattern. Same naming scheme. No exceptions. No "well, this one is slightly different because..." -- stop. If the work is parallel, the code is parallel. Period.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
If two functions do analogous work, they must have analogous structure. Same parameter order. Same return shape. Same error handling pattern. Same naming scheme. No exceptions. No "well, this one is slightly different because..." -- stop. If the work is parallel, the code is parallel. Period.
Symmetry is not aesthetics. It is how a reader builds a mental model of your codebase. When create_user and create_product have the same shape, the reader learns the pattern once and applies it everywhere. When they differ -- different parameter order, different return type, different error wrapping -- the reader must re-learn the pattern for every module. That is a tax on every person who touches the code for the rest of its life.
If two things do analogous work, they must have analogous structure.
This applies at every level:
CRUD handlers are the most common symmetry violation. Every handler for a resource must have identical structure: same logging calls, same error handling, same response shape. If create logs the operation, delete logs the operation. If read wraps errors in a ServiceError, update wraps errors in a ServiceError.
GOOD -- symmetric CRUD:
class UserService:
def create_user(self, data: CreateUserInput) -> ServiceResult[User]:
logger.info("creating user", extra={"email": data.email})
try:
user = self.repo.insert(data)
return ServiceResult(data=user, error=None)
except DatabaseError as exc:
logger.error("failed to create user", extra={"error": str(exc)})
return ServiceResult(data=None, error=ServiceError.from_exception(exc))
def get_user(self, user_id: str) -> ServiceResult[User]:
logger.info("getting user", extra={"user_id": user_id})
try:
user = self.repo.find_by_id(user_id)
return ServiceResult(data=user, error=None)
except DatabaseError as exc:
logger.error("failed to get user", extra={"error": str(exc)})
return ServiceResult(data=None, error=ServiceError.from_exception(exc))
def update_user(self, user_id: str, data: UpdateUserInput) -> ServiceResult[User]:
logger.info("updating user", extra={"user_id": user_id})
try:
user = self.repo.update(user_id, data)
return ServiceResult(data=user, error=None)
except DatabaseError as exc:
logger.error("failed to update user", extra={"error": str(exc)})
return ServiceResult(data=None, error=ServiceError.from_exception(exc))
def delete_user(self, user_id: str) -> ServiceResult[None]:
logger.info("deleting user", extra={"user_id": user_id})
try:
self.repo.remove(user_id)
return ServiceResult(data=None, error=None)
except DatabaseError as exc:
logger.error("failed to delete user", extra={"error": str(exc)})
return ServiceResult(data=None, error=ServiceError.from_exception(exc))
Every method: log entry, try/except, repo call, ServiceResult return, error wrapping. Same shape. A reader who understands one method understands all four.
BAD -- asymmetric CRUD:
class UserService:
def create_user(self, data: CreateUserInput) -> User:
# Returns raw object, no wrapper
user = self.repo.insert(data)
print(f"Created user {user.id}") # print instead of logger
return user
def get_user(self, user_id: str) -> dict:
# Returns dict instead of model
try:
return self.repo.find_by_id(user_id).__dict__
except Exception:
return None # Swallows error, returns None
def update_user(self, user_id: str, data: UpdateUserInput) -> ServiceResult[User]:
# Now suddenly uses ServiceResult
logger.info("updating user", extra={"user_id": user_id})
try:
user = self.repo.update(user_id, data)
return ServiceResult(data=user, error=None)
except DatabaseError as exc:
return ServiceResult(data=None, error=ServiceError.from_exception(exc))
def remove(self, id: str) -> bool:
# Different method name. Different param name. Returns bool.
self.repo.remove(id)
return True
Four methods, four different patterns. Different return types (User, dict, ServiceResult, bool). Different error handling (none, swallow, wrap, ignore). Different naming (delete_user vs remove, user_id vs id). This is not a service -- it is four unrelated functions that happen to share a class.
If you have open, you have close. Not disconnect. Not shutdown. Not teardown. The naming mirrors the action.
The rule: for every operation that acquires/creates/starts something, the inverse operation uses the exact antonym with the same noun.
| Create | Destroy |
|---|---|
open_connection | close_connection |
acquire_lock | release_lock |
start_timer | stop_timer |
subscribe | unsubscribe |
serialize | deserialize |
encode | decode |
push | pop |
begin_transaction | end_transaction |
register_handler | unregister_handler |
create_session | destroy_session |
BAD pairs:
| Create | Destroy | Problem |
|---|---|---|
open_connection | disconnect | Different verb, dropped noun |
acquire_lock | free_lock | free is not the antonym of acquire |
start_timer | cancel_timer | cancel implies error; stop is the antonym |
create_session | end_session | end is not the antonym of create; use destroy |
register_handler | remove_handler | remove is not the antonym of register |
If create_user(name, email, role) takes arguments in that order, then update_user(name, email, role) takes them in the same order. Not update_user(email, name, role). Not update_user(role, name, email).
GOOD:
function createUser(name: string, email: string, role: Role): Promise<User>;
function updateUser(id: string, name: string, email: string, role: Role): Promise<User>;
function deleteUser(id: string): Promise<void>;
function createProduct(name: string, price: number, category: Category): Promise<Product>;
function updateProduct(id: string, name: string, price: number, category: Category): Promise<Product>;
function deleteProduct(id: string): Promise<void>;
The pattern: create takes the fields. Update takes id first, then the same fields in the same order. Delete takes id. This holds for every resource.
BAD:
function createUser(name: string, email: string, role: Role): Promise<User>;
function updateUser(email: string, role: Role, name: string, id: string): Promise<User>;
function removeUser(userId: string): Promise<boolean>;
function createProduct(category: Category, name: string, price: number): Promise<Product>;
function modifyProduct(id: string, updates: Partial<Product>): Promise<Product>;
function deleteProduct(id: string): Promise<void>;
Parameter order shuffled. Verbs inconsistent (update/modify/remove/delete). One returns Promise<boolean>, others return the entity. One takes a partial object, others take individual fields. The reader must check every signature individually.
If getUser returns { data, error }, then getProduct returns { data, error }. Not { result, err }. Not { payload, exception }. Not just the raw object.
GOOD:
type Result[T any] struct {
Data T
Error error
}
func (s *UserService) GetUser(id string) Result[User] {
user, err := s.repo.FindByID(id)
return Result[User]{Data: user, Error: err}
}
func (s *ProductService) GetProduct(id string) Result[Product] {
product, err := s.repo.FindByID(id)
return Result[Product]{Data: product, Error: err}
}
func (s *OrderService) GetOrder(id string) Result[Order] {
order, err := s.repo.FindByID(id)
return Result[Order]{Data: order, Error: err}
}
BAD:
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id)
}
func (s *ProductService) GetProduct(id string) (ProductResult, error) {
// Returns a different wrapper type
p, err := s.repo.FindByID(id)
return ProductResult{Payload: p}, err
}
func (s *OrderService) FetchOrder(id string) map[string]interface{} {
// Different verb. Returns raw map. No error.
order, _ := s.repo.FindByID(id)
return orderToMap(order)
}
Three services, three different return conventions. A developer touching any service must first discover that service's unique return pattern instead of relying on the one they already know.
If module A wraps errors with fmt.Errorf("user: %w", err), module B wraps them with fmt.Errorf("product: %w", err). Same format string structure. Same wrapping verb. Same context level.
GOOD:
// user_service.go
func (s *UserService) Create(input CreateUserInput) (*User, error) {
user, err := s.repo.Insert(input)
if err != nil {
return nil, fmt.Errorf("user service: create: %w", err)
}
return user, nil
}
// product_service.go
func (s *ProductService) Create(input CreateProductInput) (*Product, error) {
product, err := s.repo.Insert(input)
if err != nil {
return nil, fmt.Errorf("product service: create: %w", err)
}
return product, nil
}
BAD:
// user_service.go
func (s *UserService) Create(input CreateUserInput) (*User, error) {
user, err := s.repo.Insert(input)
if err != nil {
return nil, fmt.Errorf("user service: create: %w", err)
}
return user, nil
}
// product_service.go
func (s *ProductService) Create(input CreateProductInput) (*Product, error) {
product, err := s.repo.Insert(input)
if err != nil {
return nil, errors.New("failed to create product: " + err.Error())
// Different wrapping style. Uses errors.New instead of fmt.Errorf.
// Loses the error chain (%w). Different message format.
}
return product, nil
}
Test files for similar modules should mirror each other. Same describe/it structure. Same setup pattern. Same assertion helpers. If someone can look at user_test.go and predict the shape of product_test.go, the tests are symmetric.
GOOD:
// user.test.ts
describe("UserService", () => {
let service: UserService;
let repo: MockUserRepo;
beforeEach(() => {
repo = new MockUserRepo();
service = new UserService(repo);
});
describe("create", () => {
it("returns the created user", async () => { /* ... */ });
it("throws ValidationError on invalid input", async () => { /* ... */ });
it("wraps database errors in ServiceError", async () => { /* ... */ });
});
describe("getById", () => {
it("returns the user when found", async () => { /* ... */ });
it("throws NotFoundError when missing", async () => { /* ... */ });
});
});
// product.test.ts -- same shape
describe("ProductService", () => {
let service: ProductService;
let repo: MockProductRepo;
beforeEach(() => {
repo = new MockProductRepo();
service = new ProductService(repo);
});
describe("create", () => {
it("returns the created product", async () => { /* ... */ });
it("throws ValidationError on invalid input", async () => { /* ... */ });
it("wraps database errors in ServiceError", async () => { /* ... */ });
});
describe("getById", () => {
it("returns the product when found", async () => { /* ... */ });
it("throws NotFoundError when missing", async () => { /* ... */ });
});
});
The most common symmetry violation starts with a reasonable-sounding excuse: "This function is slightly different, so I will handle it slightly differently." Maybe delete does not need to return the entity. Maybe getProduct can skip error wrapping because "it is simple." Maybe closeConnection is called teardown because "it does more than just closing."
Every one of these "local optimizations" breaks the global pattern. The reader now has two patterns to track instead of one. Then a third developer adds a third variation. Within six months, every function is a special case and no one can predict the shape of anything.
The fix is simple: match the existing pattern. If the pattern is wrong, change it everywhere. Never create a one-off variation.
Working implementations in examples/:
examples/parallel-structure.md -- Multi-language examples showing symmetric vs asymmetric code paths for CRUD operations and matching pairs in Python, TypeScript, and GoWhen reviewing code that involves parallel operations:
open_connection/close_connection, not open_connection/disconnect)